[{"name":"Actor","class":"Core","subsystem":"CONFIG","area":"Configuration","desc":"A platform identity that can own or consume resources, hold configurations, and appear in audit trails. Actors are split into two kinds: Standard actors (human-tied — associated to a User entity) and Service actors (system, service, or application identities with no User association). Actor is the canonical principal across the platform — services, audit logs, and tool-level RBAC reference Actor rather than User directly so that human and non-human principals can be authorized and attributed uniformly. Resolves to a User only when the Actor is Standard.","status":"draft","properties":[{"n":"actorId","r":true,"t":"string"},{"n":"actorType","r":true,"t":"enumeration","v":["Standard","Service"],"info":"Discriminates the kind of identity this Actor represents. Standard = human-tied; the Actor MUST be associated to a User via the user property. Service = system, service, or application identity; the Actor MUST NOT have a user value set. Service actors are how non-human principals (background workers, integration accounts, external system credentials) participate in audit trails and RBAC without polluting the User entity."},{"n":"name","r":true,"t":"string"},{"n":"user","t":"entityDetail","re":"User","info":"The human User this Actor represents. Populated only when actorType = Standard; must be null/absent when actorType = Service. Modeled as entityDetail rather than entityRef because both Actor and User are Company-scoped — the reference must respect the Company isolation boundary. Conditional-required relationship enforced via business validation rather than the property being unconditionally required."}],"service":"APR Config","shopify":"API client credentials / app installations","related":["Config","Secret","Environment","User"],"bv":{"rules":[{"rule":"user must be set when actorType is Standard — a Standard Actor represents a human and must resolve to a User entity","when":"always","field":"user","severity":"error"},{"rule":"user must be null/absent when actorType is Service — a Service Actor represents a non-human identity and must not be associated to a User","when":"always","field":"user","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Advanced Shipping Notice","class":"Operational","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"Advance Shipment Notice from a Vendor confirming goods in transit, broken into Cartons.","status":"stub","properties":[{"n":"asnId","r":true,"t":"string"},{"n":"cartons","r":true,"t":"array","re":"Advanced Shipping Notice Carton"},{"n":"eta","t":"datetime"},{"n":"purchaseOrder","r":true,"t":"entityRef","re":"Purchase Order"},{"n":"vendor","r":true,"t":"entityRef","re":"Vendor"}],"ext":"OperationalDocument","shopify":"EDI 856","related":["Purchase Order","Vendor","Advanced Shipping Notice Carton","Goods Receipt"],"bv":{"rules":[],"lifecycle":{"states":["Pending","Received","PartiallyReceived","Discrepant"],"transitions":[{"to":"Received","from":"Pending","conditions":["All items matched to receipt"]},{"to":"PartiallyReceived","from":"Pending","conditions":["Some items received"]},{"to":"Discrepant","from":"Pending","conditions":["Received quantities do not match ASN"]}],"initialState":"Pending"},"calculations":[],"crossEntityConstraints":[{"rule":"Should reference a valid Purchase Order for reconciliation","entity":"Purchase Order"}]}},{"name":"Advanced Shipping Notice Carton","class":"Operational","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"Individual carton within an ASN shipment, tracking packed items at the box level.","status":"stub","properties":[{"n":"asn","r":true,"t":"entityRef","re":"Advanced Shipping Notice"},{"n":"cartonId","r":true,"t":"string"},{"n":"cartonNo","t":"string"},{"n":"lines","r":true,"t":"array","info":"Array of AdvancedShippingNoticeLine sub-documents within this ASN carton."},{"n":"trackingNo","t":"string"},{"n":"weight","t":"decimal"}],"ext":"OperationalSubDocument","related":["Advanced Shipping Notice","Goods Receipt"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Must belong to a valid ASN","entity":"Advanced Shipping Notice"}]}},{"name":"Announcement","class":"Operational","subsystem":"CONNECT","area":"Content","desc":"A tenant-scoped, push-style communication published to a targeted audience within a Company. The fan-out member of the ContentDocument family: where a Content Post waits in a feed and a Content Page waits in navigation, an Announcement is pushed to its audience across every channel the Company is entitled to — portal banner, in-app feed, email, mobile push — and may require acknowledgement. Everything an Announcement shares with other authored content (title, body, summary, author, publishing lifecycle, scheduling window, audience targeting, attachments, hero imagery, pinning) is inherited from ContentDocument. What remains here is exactly what makes it an Announcement rather than a post: category and priority, the acknowledgement requirement, the set of delivery channels, and the rollups over its acknowledgement ledger. Companion entity Announcement Acknowledgement (Ledger) records per-user view/ack/dismiss events for audit and rollup.","status":"draft","properties":[{"c":true,"n":"acknowledgementCount","r":true,"t":"integer","info":"Calculated rollup. COUNT(Announcement Acknowledgement WHERE announcement = this AND acknowledgement = 'acknowledged'). Query-time."},{"n":"announcementNo","r":true,"t":"string","u":true,"info":"Human-readable identifier, unique within Company. Per entity-no-property convention."},{"n":"category","r":true,"t":"enumeration","v":["operational","policy","training","marketing","system","compliance"],"info":"Classifies the announcement purpose. Drives surface treatment, filtering, and (for compliance/policy) default isAcknowledgementRequired. category='compliance' bypasses recipient category opt-out, do-not-disturb and quiet hours — asserting it is a privileged action and must be permission-gated, not merely author-settable."},{"n":"deliveryChannels","r":true,"t":"array","info":"Set of surfaces to fan out to. Allowed values: 'portal', 'inAppBanner', 'email', 'mobilePush'. When 'email' is included, a record is written to Email Log on send; when 'mobilePush' is included, one Push Notification Log entry per resolved recipient device. This property is what distinguishes Announcement from its ContentDocument siblings — Content Post and Content Page are pull content and declare no delivery channels."},{"n":"isAcknowledgementRequired","r":true,"t":"boolean","info":"When true, targeted users must record an 'acknowledged' Announcement Acknowledgement before the announcement can be dismissed. Per boolean-must-be-required."},{"n":"priority","r":true,"t":"enumeration","v":["info","important","urgent"],"info":"Drives surface treatment — info renders inline, important shows a banner, urgent triggers a modal/toast and push notification regardless of deliveryChannels. priority='urgent' bypasses recipient preferences and quiet hours and, like category='compliance', must be permission-gated."},{"c":true,"n":"viewCount","r":true,"t":"integer","info":"Calculated rollup. COUNT(DISTINCT user) from Announcement Acknowledgement WHERE announcement = this AND acknowledgement IN ('viewed','acknowledged','dismissed'). Query-time."}],"ext":"ContentDocument","notes":"PROPOSED — not yet reviewed. Re-parented 31 Aug 2026 from OperationalDocument to the new ContentDocument base schema, at the same time as Content Post and Content Page were drafted and the area moved from Messaging to Content.\n\nWHY THIS WAS A REWRITE RATHER THAN AN EDIT: the registry's update_entity cannot change an entity's base schema, so re-parenting required delete and recreate under the same name. All business validation rules, lifecycle states, calculations and cross-entity constraints were carried forward, and several were added rather than removed.\n\nMOVED TO ContentDocument (no longer declared here, unchanged in meaning): title, body, summary, author, status, publishAt, publishedAt, expireAt, audience, targetLocations, targetRoles, attachments, isPinned. Thirteen properties, now shared with Content Post and Content Page.\n\nCHANGED IN THE MOVE: bannerImageUrl (string) became the inherited heroMedia (entityRef -> Media). An Announcement no longer carries a raw image URL; it references a governed asset, so the header image on a franchise-scoped announcement inherits that asset's visibility instead of being a public link anyone can forward. This is a behaviour change, not a rename, and is the one property that did not survive the re-parent unmodified.\n\nThe audience valueType is now ContentAudience rather than AnnouncementAudience. Shape unchanged; AnnouncementAudience was retired because its sole consumer was this entity.\n\nADDED IN THIS PASS: permission gates on category='compliance' and priority='urgent'. These were argued in the messaging service boundary decision (§5.3) as shipping alongside the bypass they govern — an override with no gate on who may invoke it is an unenforceable control — but were not previously expressed as validation rules. The gate must cover any source asserting 'compliance', not only the Announcement authoring screen.\n\nPUSH: the 'mobilePush' delivery channel and the priority='urgent' push override resolve against Push Device, Notification Preference and Push Notification Log. Fan-out targets Push Devices (status='active', isEnabled=true) owned by resolved recipients, gated by Notification Preference, and writes one Push Notification Log entry per recipient device.\n\nOpen questions carried forward: (1) cross-Company franchisor broadcasts — fan-out at publish or parentAnnouncement self-ref; (2) Announcement Template (mirror of Email Template) for recurring patterns — note this question now generalizes to a ContentDocument-level template concept rather than an Announcement-specific one; (3) versioning post-publish — void+re-publish vs version-in-place, now shared with Content Page which needs it more acutely; (4) coupling to Email Template when 'email' is in deliveryChannels; (5) does an Announcement need a per-channel opt-out of its own, or is Notification Preference the only gate; (6) push copy currently derives from title/summary with no template.","related":["User","Location","Scope","Media","Email Template","Email Log","Announcement Acknowledgement","Push Notification Log","Notification Preference","Push Device","Content Post","Content Page"],"bv":{"rules":[{"rule":"Must be >= now() on create or transition to 'scheduled'","when":"create","field":"PublishAt","severity":"error"},{"rule":"If set, must be > publishAt","when":"always","field":"ExpireAt","severity":"error"},{"rule":"Immutable once status = 'published'","when":"update","field":"PublishedAt","severity":"error"},{"rule":"When audience.scope = 'all', targetLocations and targetRoles must be empty","when":"always","field":"Audience","severity":"error"},{"rule":"All targetLocations must belong to the same Company as this Announcement","when":"always","field":"TargetLocations","severity":"error"},{"rule":"Must contain at least one value when status moves to 'scheduled' or 'published'","when":"publish","field":"DeliveryChannels","severity":"error"},{"rule":"Allowed values are 'portal', 'inAppBanner', 'email', 'mobilePush'","when":"always","field":"DeliveryChannels","severity":"error"},{"rule":"Setting category = 'compliance' requires a permission the ordinary author role does not carry — it bypasses every recipient notification preference","when":"publish","field":"Category","severity":"error"},{"rule":"Setting priority = 'urgent' requires a permission the ordinary author role does not carry — it bypasses do-not-disturb and quiet hours","when":"publish","field":"Priority","severity":"error"},{"rule":"Defaults to true when category is 'compliance' or 'policy'","when":"create","field":"IsAcknowledgementRequired","severity":"warning"}],"lifecycle":{"states":["Draft","Scheduled","Published","Expired","Archived"],"transitions":[{"to":"Scheduled","from":"Draft","conditions":["publishAt is set and >= now()"]},{"to":"Published","from":"Draft","conditions":["Author publishes immediately"]},{"to":"Published","from":"Scheduled","conditions":["publishAt reached; system publishes"]},{"to":"Draft","from":"Scheduled","conditions":["Author cancels scheduled publish"]},{"to":"Expired","from":"Published","conditions":["expireAt reached"]},{"to":"Archived","from":"Published","conditions":["Admin manually archives"]},{"to":"Archived","from":"Expired","conditions":["Admin manually archives"]}],"initialState":"Draft"},"calculations":[{"name":"acknowledgementCount","formula":"COUNT(Announcement Acknowledgement WHERE announcement = this AND acknowledgement = 'acknowledged')","trigger":"query-time"},{"name":"viewCount","formula":"COUNT(DISTINCT user) FROM Announcement Acknowledgement WHERE announcement = this","trigger":"query-time"},{"name":"audienceSize","formula":"resolved size of audience filter (franchiseGroups ∩ targetLocations ∩ targetRoles + includeUsers - excludeUsers)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Author and any User in audience.includeUsers/excludeUsers must belong to the same Company","entity":"User"},{"rule":"All targetLocations must belong to the same Company as this Announcement","entity":"Location"},{"rule":"When 'email' is in deliveryChannels, an Email Log entry is written per recipient at send time","entity":"Email Log"},{"rule":"When 'email' is in deliveryChannels, an Email Template may be referenced for subject/body override (open question — not currently a property)","entity":"Email Template"},{"rule":"When 'mobilePush' is in deliveryChannels, or priority = 'urgent', a Push Notification Log entry is written per resolved recipient per active Push Device at publish. Recipients excluded by preference or quiet hours are written with status='suppressed', never omitted","entity":"Push Notification Log"},{"rule":"Each resolved recipient's Notification Preference is evaluated before push and email fan-out. priority='urgent' and category='compliance' bypass category opt-out, do-not-disturb, and quiet hours","entity":"Notification Preference"},{"rule":"Push fan-out targets only Push Devices with status='active' and isEnabled=true belonging to resolved recipients","entity":"Push Device"},{"rule":"Inherited attachments and heroMedia must reference Media with purpose in ('ContentAsset','Attachment','BrandAsset'). An Announcement delivered by email or push renders its heroMedia to recipients, so a restricted asset on a broadly-targeted announcement is a disclosure defect","entity":"Media"}]}},{"name":"Announcement Acknowledgement","class":"Ledger","subsystem":"CONNECT","area":"Content","desc":"Immutable, append-only record of a User's interaction with an Announcement — viewed, acknowledged (when required), or dismissed. One row per User per state-change. Companion ledger to Announcement; the source of truth for viewCount/acknowledgementCount rollups and the audit trail for compliance-category announcements. Mirrors the Email Log pattern: ledger-class, no modifiedBy/modifiedDate (per ledger-entry-immutability), corrections via new compensating entries.","status":"draft","properties":[{"n":"acknowledgement","r":true,"t":"enumeration","v":["viewed","acknowledged","dismissed","expired_unacknowledged"],"info":"The state recorded. 'viewed' = surfaced to user; 'acknowledged' = explicit confirmation when isAcknowledgementRequired; 'dismissed' = user dismissed without ack (only when isAcknowledgementRequired = false); 'expired_unacknowledged' = system-written at expireAt for required announcements that were never acknowledged."},{"n":"acknowledgementNo","r":true,"t":"string","u":true,"info":"Human-readable identifier, unique within Company. Per entity-no-property convention."},{"n":"announcement","r":true,"t":"entityRef","re":"Announcement","info":"The Announcement this acknowledgement is for. Drives the rollup on Announcement.acknowledgementCount/viewCount."},{"n":"clientContext","t":"valueType","info":"Captures the surface and device on which the state change occurred (e.g. surface='portal'|'mobile'|'email', deviceType, appVersion). Optional — system-recorded entries may omit."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant isolation boundary. Inherited from the parent Announcement's Company at write time. Per company-scoping-required."},{"n":"user","r":true,"t":"entityRef","re":"User","info":"The User whose interaction is recorded. Null is not permitted — for 'expired_unacknowledged' rows, the system writes one row per targeted user who failed to ack."}],"ext":"LedgerEntry","notes":"Moved from the Messaging area to Content on 31 Aug 2026, following its parent Announcement. The companion ledger stays adjacent to the entity it records interactions with.\n\nUnchanged by the ContentDocument re-parenting: this ledger references Announcement, and Announcement's identity, class and acknowledgement semantics were all preserved through that change. Announcement's acknowledgementCount and viewCount rollups still compute over this ledger with the same formulas.\n\nWorth noting for the Content module: no equivalent ledger exists for Content Post or Content Page, so neither has a viewCount. Whether pull content needs view tracking — and whether it warrants a ledger or something lighter — is an open question logged against Content Post.","related":["Announcement","User","Company"],"bv":{"rules":[{"rule":"Must reference a published or expired Announcement (not draft/scheduled)","when":"create","field":"Announcement","severity":"error"},{"rule":"Must belong to the same Company as the referenced Announcement","when":"create","field":"Company","severity":"error"},{"rule":"Must be a User in the same Company as the Announcement","when":"create","field":"User","severity":"error"},{"rule":"'acknowledged' is only valid when the parent Announcement.isAcknowledgementRequired = true","when":"create","field":"Acknowledgement","severity":"error"},{"rule":"'expired_unacknowledged' may only be written by the system, not by users","when":"create","field":"Acknowledgement","severity":"error"},{"rule":"Append-only; no modifications or deletions permitted (per ledger-entry-immutability)","when":"always","field":"*","severity":"error"}],"crossEntityConstraints":[{"rule":"Each Announcement Acknowledgement row points to one Announcement and one User; the (announcement, user, acknowledgement) tuple is the unique audit fact","entity":"Announcement"},{"rule":"User must be within the resolved audience of the Announcement at the time of write","entity":"User"}]}},{"name":"Application","class":"Core","subsystem":"ALLPOINT","area":"Organization","desc":"A registered application that interacts with the All Point platform. This covers both APR platform products (CONNECT, Fulcrum X, APR Config) and APR-developed apps for external platforms (e.g., APR's Shopify App, APR's NetSuite Integration App). Defines the product's capabilities and supported features. Applications exist in a global registry, independent of any Client or Company. They are deployed to Companies as Application Installations.","status":"draft","properties":[{"n":"applicationNo","r":true,"t":"string","u":true,"info":"Human-readable business identifier"},{"n":"description","t":"string"},{"n":"developer","r":true,"t":"enumeration","v":["all-point","3rd-party"],"info":"Who built the application. all-point (developed by APR) or 3rd-party (developed by a third party)"},{"n":"distribution","r":true,"t":"enumeration","v":["public","all-point","custom"],"info":"How the application is made available. public (publicly listed), all-point (internal to APR), custom (built for a specific company)"},{"n":"name","r":true,"t":"string","u":true},{"n":"platform","r":true,"t":"enumeration","v":["all-point","shopify"],"info":"Where the application runs. all-point (runs on APR's platform) or shopify (runs on Shopify)"},{"n":"slug","r":true,"t":"string","u":true,"info":"Human-readable key, globally unique (e.g. 'portal' for the franchise portal, 'shopify-app', 'netsuite-app'). Lowercase, hyphenated if multi-word. Redeclared from CoreEntity to tighten the inherited slug to required and unique, following the LookupEntity.company precedent for constraint-tightening overrides.\n\nIMMUTABLE ONCE PERMISSIONS EXIST. The slug is selected when a Permission is created and copied into the FIRST SEGMENT of that permission's code (portal:product:read). Permission codes are then held as bare strings by Roles, Scopes and issued tokens, with no foreign key back to this entity — so a slug rename does not cascade, it orphans. Every code carrying the old slug keeps working as a string while matching nothing, and every guard checking one silently stops granting access. Renaming a slug is a full permission-catalogue migration (seed codes under the new slug, regrant Roles, deactivate the old codes), never an edit. Treat the slug as part of the platform's public contract from the moment the first Permission references it."},{"n":"supportedFeatures","r":true,"t":"array","info":"Feature capabilities this Application supports (e.g. 'inventory', 'orders', 'reporting'). Used to validate Application Configuration feature flags. Empty array is the minimum default."},{"n":"type","r":true,"t":"enumeration","v":["web-application","service","embedded"],"info":"What the application is technically. web-application (Web/React app), service (microservice), embedded (embedded app)"}],"ext":"CoreEntity","notes":"Canonical subsystem is PLATFORM. Currently registered under ALLPOINT pending subsystem reassignment — needs delete-and-recreate to move to PLATFORM. Extends CoreEntity (not BaseDocument) because Application is a global-registry entity independent of any Client or Company, so the franchiseGroups property BaseDocument provides would be meaningless here; CoreEntity also supplies the slug field this entity needs. The former capital-'Id' string surrogate key was removed in favour of the inherited UUID id per no-redundant-entity-id.","related":["Application Installation","Permission","Role"],"bv":{"rules":[{"rule":"Must be globally unique","when":"create","field":"slug","severity":"error"},{"rule":"slug is immutable once any Permission has been seeded against this Application. The slug is copied into the first segment of every permission code, and those codes are held as bare strings by Roles, Scopes and issued tokens with no foreign key back here — so a rename orphans rather than cascades. Changing it requires a full permission-catalogue migration (seed under the new slug, regrant Roles, deactivate the old codes), not an edit.","when":"update","field":"slug","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Deactivating an Application must revoke all associated Client tokens","entity":"Client"},{"rule":"The first segment of every Permission code is this Application's slug, selected at permission-creation time. The relationship is by string value only — there is no reference from Permission back to Application — which is why the slug must be treated as immutable.","entity":"Permission"},{"rule":"Every Role is scoped to exactly one Application, and every Permission it grants must carry that Application's slug as the first segment of its code. Deactivating an Application should deactivate the Roles scoped to it, since their permissions no longer resolve anywhere.","entity":"Role"}]}},{"name":"Application Installation","class":"Core","subsystem":"CONNECT","area":"Organization","desc":"A Company-scoped installation of an Application. This is the entity created when an Application is actually deployed or installed for a specific Company — whether that's deploying Fulcrum X for a franchise group, installing APR's Shopify app on a merchant's Shopify store, or setting up API credentials for NetSuite. Application Installation holds the authenticated connection state: OAuth tokens, API keys, external account identifiers, and health status. It is the credentials and identity boundary for a Company's use of an Application.","status":"draft","properties":[{"n":"application","r":true,"t":"entityRef","re":"Application","info":"The Application this is an installation of"},{"n":"authStatus","r":true,"t":"enumeration","v":["pending_install","pending_auth","connected","error","disconnected"],"info":"SYSTEM-OBSERVED STATE — the actual installation and authorization state, written only by the system (install flow, auth flow, token refresh, health check). Never set by an operator. pending_install = record created, install not completed. pending_auth = installed, not yet authenticated. connected = authenticated and usable. error = authentication or access failing. disconnected = access revoked or withdrawn at the external system. Carries the five values moved out of status when it was narrowed to operator intent. Mirrors ConnectionCredentials.authStatus, minus nothing — the extra pending_install value has no analogue there because a Connection record is itself the installation."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"The Company tenant this installation belongs to. Defines the hard isolation boundary for queries, permissions, replication, and export."},{"n":"configuration","t":"schema","info":"Application-specific config (API version, sync preferences, feature toggles)"},{"n":"credentials","r":true,"t":"encrypted","info":"OAuth tokens, API keys, secrets (encrypted at rest)"},{"n":"displayName","r":true,"t":"string","info":"Operator-facing label (e.g., Acme Shopify Store, Acme Fulcrum X)"},{"n":"externalAccountId","t":"string","info":"Account/store/tenant ID on the external system (e.g., Shopify store ID, NetSuite account ID). Not redundant with the inherited id — this identifier is issued by the external system, and is explicitly excluded from no-redundant-entity-id."},{"n":"externalAccountUrl","t":"string","info":"URL to the account on the external system (e.g., acme.myshopify.com)"},{"n":"healthStatus","t":"enumeration","v":["healthy","degraded","failing","unknown"],"info":"Current health of the installation"},{"n":"installationNo","r":true,"t":"string","u":true,"info":"Human-readable business identifier"},{"n":"installedAt","t":"datetime","info":"When the installation completed. Deliberately retained alongside the inherited createdDate: the record is created at pending_install and reaches connected later, so the install event and the record-creation event are genuinely different moments."},{"n":"installedBy","t":"entityRef","re":"User","info":"User who set up the installation. Retained alongside the inherited createdBy because it is a typed User reference with domain meaning, and because the installer need not be whoever created the record."},{"n":"lastHealthCheckAt","t":"datetime","info":"Last time health was verified"},{"n":"licensing","r":true,"t":"valueType","info":"LicenseGrant value type holding the licence this installation operates under — the tier granted, the contract funding it, its window, and any negotiated overrides. Required as a container so licenseStatus always has a home, exactly as Connection's sourceCredentials/destinationCredentials are required so authStatus always has one; an installation nobody has bought anything for carries licenseStatus 'unlicensed' rather than a null licensing block. The referenced tier must belong to this installation's application. This replaces the former Company.tier, which attached licensing to the tenant boundary — the wrong grain, since a Company may run several Applications on different plan levels."},{"n":"status","r":true,"t":"enumeration","v":["draft","active","suspended","archived"],"info":"OPERATOR INTENT — what a human wants this installation to do. Set only by a user, never by the system. draft = being set up, not yet meant to be live. active = intended to be in use. suspended = deliberately stopped by an operator. archived = retired. Observed system state lives in authStatus and healthStatus. The two are independent and must not be derived from each other: suspending an installation does not change authStatus, and a revoked token does not move status off active. Effective usability is the conjunction — status is active AND authStatus is connected. An active installation whose authStatus is not connected is BLOCKED, not suspended, and must be surfaced to the operator as such. The former values pending_install, pending_auth, connected, error and disconnected were moved to authStatus; suspended remains here as the one genuine operator intent in the original enum."}],"ext":"BaseDocument","notes":"Extends BaseDocument because Application Installation is genuinely Company-scoped tenant data, so the inherited franchiseGroups is meaningful. The former capital-'Id' string surrogate key was removed in favour of the inherited UUID id per no-redundant-entity-id. installedBy and installedAt were retained rather than collapsed into the inherited createdBy/createdDate, on the grounds that a record created at pending_install and connected later has two distinct events; revisit if that distinction turns out not to be used. status was subsequently split into operator intent (status) and system-observed state (authStatus), matching the same split on Connection — see businessValidation.rules for the semantics and the record-level migration mapping.","related":["Application","Company","Licensing Tier","Subscription","User"],"bv":{"rules":[{"rule":"status is operator intent and is writable only by a user. No system process (install flow, auth flow, token refresh, health check, billing) may write to status. Observed state is reported via authStatus, healthStatus and licensing.licenseStatus, which conversely are written only by the system and never by an operator.","when":"always","field":"status","severity":"error"},{"rule":"Effective usability is the conjunction of intent, connectivity and entitlement: an installation may be used to execute work only when status is 'active' AND authStatus is 'connected' AND licensing.licenseStatus is 'active' or 'trial'. The three axes are independent and each failure must be reported as itself, because each demands a different action from a different person. status not 'active' = SUSPENDED (an operator stopped it). status 'active' with authStatus not 'connected' = BLOCKED (nobody stopped it; someone must re-authenticate). status 'active', authStatus 'connected', licenseStatus not licensed = UNLICENSED (nothing is broken; someone must buy or renew). Collapsing any of these into another misattributes the cause and hides the action the operator needs to take.","when":"the installation is used","field":"status","severity":"error"},{"rule":"licensing.licenseStatus is system-observed and writable only by the billing system (purchase, renewal, dunning, cancellation, trial expiry). No operator sets it, and no install, auth or health process writes it. An operator who wants to stop an installation sets status to 'suspended'; that must not change licenseStatus, and a lapsed licence must not move status off 'active'.","when":"always","field":"licensing","severity":"error"},{"rule":"licensing.tier must reference a Licensing Tier whose application matches this installation's application. A grant pointing at another product's tier is unresolvable — its includedFeatures are drawn from a different capability vocabulary.","when":"create","field":"licensing","severity":"error"},{"rule":"A grant does not resolve as licensed if its funding Subscription is not status 'active', regardless of what licenseStatus currently reads. The billing system is responsible for writing licenseStatus to 'expired' or 'cancelled' when a contract ends; consumers should nonetheless treat funding validity as part of resolution rather than trusting a possibly-stale status.","when":"the installation is used","field":"licensing","severity":"error"},{"rule":"MIGRATION: status was narrowed from [pending_install, pending_auth, connected, error, disconnected, suspended] to the operator intents [draft, active, suspended, archived], and the five system values moved to the new authStatus property. Remap existing records: pending_install → status 'draft' + authStatus 'pending_install'; pending_auth → status 'active' + authStatus 'pending_auth'; connected → status 'active' + authStatus 'connected'; error → status 'active' + authStatus 'error'; disconnected → status 'active' + authStatus 'disconnected'; suspended → status 'suspended' + authStatus carried from the last observed value, or 'disconnected' where none was recorded. Note that 'suspended' is the one case where the original enum destroyed information — a suspended record's underlying auth state was never stored, so it cannot be recovered and must be re-derived by a health check after migration.","when":"migration","field":"status","severity":"warning"},{"rule":"MIGRATION (licensing, 15 Sep 2026, revised 16 Sep 2026): licensing is required, so every existing installation needs a grant backfilled, and there is nothing to derive one from. The Company.tier property this model replaces was declared on Company but never implemented, so no tenant carries a recorded plan level and no automated mapping is possible — every existing Application Installation backfills to licenseStatus 'unlicensed' and must be reconciled against the contract record by hand. Recommended sequencing: backfill to 'unlicensed', reconcile against contracts, run enforcement in report-only mode for one billing period, then enable. Switching straight to hard enforcement on an unreconciled backfill would cut off paying customers. The one advantage of the old property never having been implemented is that there is no wrong data to carry forward — only an absence to fill deliberately.","when":"migration","field":"licensing","severity":"warning"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"licensing.tier must belong to this installation's application. Deactivating a tier (isActive false) must not invalidate installations already granted it; deactivation blocks new grants only.","entity":"Licensing Tier"},{"rule":"The grant's funding Subscription must belong to the Organization that owns this installation's Company. A grant funded by an unrelated subscriber's contract is a billing leak.","entity":"Subscription"},{"rule":"Effective feature access is the intersection of what the tier grants and what permission codes allow: the tier says which capabilities are bought, the Application-scoped Permission codes say which actions a principal may take within them. Neither substitutes for the other — an unlicensed feature must be refused even to a user holding its permission, and a licensed feature must still be permission-checked.","entity":"Application"}]}},{"name":"Area","class":"Dictionary","subsystem":"ACCESS","area":"Security & Permissions","desc":"A logical grouping of related Permissions — roughly a resource domain (e.g. 'Inventory', 'Orders'). Areas organise the permission catalogue for administration and give Scopes a coarse unit to bundle, so an OAuth2 scope can grant 'everything in Inventory' without enumerating every code. Platform-global alongside Permission: seeded per Application by migration or system process, identical across tenants, so company is null. Area membership is a property of the Permission (Permission.area), and the permissions array here is the reverse view of that same relationship.","status":"draft","properties":[{"n":"description","t":"string","info":"Optional long-form explanation of the resource domain this Area covers."},{"n":"permissions","r":true,"t":"array","re":"Permission","info":"The Permissions belonging to this Area. Reverse view of Permission.area, which is the authoritative side of the relationship — a permission is assigned to exactly one Area. Empty array is the minimum default."}],"service":"APR Access","ext":"LookupEntity","notes":"Rebased onto LookupEntity 01 Sep 2026 (delete-and-recreate, since extends is fixed at creation), together with Permission, Scope and Role. The former areaId string was dropped in favour of the inherited UUID id per no-redundant-entity-id; code and name now come from the base schema. Platform-global, so company is null — exempt from tenant-scoped-dictionary-declares-company alongside Currency, Locale, Country, Environment and Permission.","related":["Permission","Scope"],"bv":{"rules":[{"rule":"code is required and globally unique, and identifies the Area in Scope definitions. Lowercase, hyphenated if multi-word, matching the casing rules for permission code segments.","when":"create","field":"code","severity":"error"},{"rule":"isReadOnly must be true on every seeded Area. The catalogue is authored by migration and system processes, not by tenant users.","when":"create","field":"isReadOnly","severity":"error"},{"rule":"An Area is deactivated (isActive false), never deleted, while any Permission still references it — deleting it would orphan Permission.area.","when":"delete","field":"isActive","severity":"error"}],"lifecycle":null,"calculations":[{"name":"permissionCount","formula":"COUNT(permissions)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Every Permission must reference exactly one Area. Area.permissions is the reverse view and must agree with Permission.area.","entity":"Permission"},{"rule":"A Scope may bundle whole Areas as well as individual Permissions.","entity":"Scope"}]}},{"name":"Attribute","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Defines a product dimension (e.g. Color, Material). A combination of attribute values uniquely identifies an Item.","status":"draft","properties":[{"n":"alias","t":"string"},{"n":"attributeValues","r":true,"t":"schema","info":"Array of AttributeValue inline schemas. Each value extends LookupEntityValue — inherits id, identifiers, code, name, aliases, isActive, isDeleted, isDefault, sequence, audit fields, and customData."},{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity where company is optional so platform-global dictionaries (Currency, Locale, Country, Hs Code, Permission, Area, Scope, Environment) can omit it. Product dimensions are that tenant's own vocabulary, per tenant-scoped-dictionary-declares-company."},{"n":"name","r":true,"t":"string","u":true}],"ext":"LookupEntity","shopify":"ProductOption resource","notes":"CANONICAL VALUE RESOLUTION (added 03 Sep 2026)\n\nRESOLUTION ALGORITHM — specified here once so every caller behaves identically:\n  1. Exact match on code (case-sensitive) -> resolve.\n  2. normalize(input) matches normalize(code) or normalize(name) -> resolve.\n  3. normalize(input) matches an active synonyms[].normalizedValue -> resolve.\n  4. No match -> reject the row (default), or stage a pending value for review where the Attribute's Config permits auto-creation.\nWith U1-U4 holding, at most one step can match, so the ordering is a performance detail rather than a tie-break. The resolver therefore has no precedence logic to get wrong.\n\nAdditional resolution decisions:\n- Resolution always returns the canonical AttributeValue id.\n- Inactive canonicals still resolve, but the import report must flag that the target is inactive. Failing outright turns a data-quality signal into a blocked load.\n- NON-GOAL: no fuzzy matching in the resolver. Deterministic normalization plus an explicit synonym list only. Trigram or edit-distance matching in a master-data write path produces silent wrong merges that surface months later as inventory variance.\n\nWHY NORMALIZATION COMES FIRST: the fold collapses 'X Large', 'X-Large', 'x_large' and 'xlarge' to the single key 'xlarge'. A canonical 'XL' therefore needs two synonym rows - 'xlarge' and 'extralarge' - not the five observed variants. Hand-maintained synonym lists rot in proportion to their length, so the mechanical part is done identically everywhere by the platform function rather than by enumeration.\n\nCANONICAL HYGIENE IS THE OPERATOR'S RESPONSIBILITY. U1-U4 catch mechanical duplication; they cannot catch two canonicals that merely mean the same thing. Two values that are really one value should never be created in the first place. The system assists with NON-BLOCKING similarity advisories, surfaced at authoring time in the admin UI only - never at import time, never blocking a write:\n  A1 - New canonical collides with an existing synonym. Highest value of the three and free from the resolution-key projection: creating a value named 'X-Large' while 'xlarge' is already a synonym of 'XL' is about to create exactly the duplicate the synonym existed to prevent. Warrants a confirmation step, not a passive hint. OPEN: confirm strength.\n  A2 - Near-miss against another canonical. Trigram similarity (pg_trgm) or edit distance (fuzzystrmatch) against every other canonical in the set, threshold tuned for short strings (~similarity > 0.7, or Levenshtein <= 2 under 8 characters). Hint only: S/M/L/XL are all near-misses of one another and all legitimate.\n  A3 - Set health report. A standing check listing suspicious canonical pairs per Attribute, so drift predating these rules or arriving through a migration is reviewed in one pass rather than discovered one item at a time.\nThe line between the businessValidation rules and these advisories is deliberate: deterministic collisions are errors, judgement calls are hints.\n\nPLATFORM SEED VS TENANT EXTENSION: Attribute is tenant-scoped, so synonyms are per-Company by default, but the Size and Color variant sets are effectively universal. Seed them centrally with source 'platform', read-only in the tenant UI, and let tenants extend with source 'manual'. U1-U4 span both, so a tenant cannot add a synonym colliding with a seeded one.\n\nSHOPIFY ASYMMETRY: Shopify's ProductOption values are free text with no canonical/variant concept - every inbound spelling becomes a distinct option value. This model is deliberately stricter than the mapped Shopify shape; the asymmetry is intentional, not an oversight, and should be recorded in the mapping documentation.\n\nKNOWN OPEN ITEMS: (1) whether LookupEntity heads (Brand, Vendor names) need the same inbound resolution one level up; (2) whether unmatched values reject or stage as pending, which likely belongs on the Attribute's Config record rather than being hard-coded; (3) migration - existing tenants may hold duplicate values that are variant spellings of one another, and standing U1/U2 up requires a merge pass with Item re-keying, a separate workstream that should not gate the schema change.\n\nFull proposal: project doc claude/attribute-value-synonym-proposal.md","related":["Product","Item"],"bv":{"rules":[{"rule":"H1 — code must be unique within the Attribute type, i.e. across all Attribute records in the tenant (Company). Two Attributes named the same dimension cannot both exist, because Product.attributes and the Item keying model resolve a dimension by its Attribute identity; a duplicate dimension silently splits which values are legal for an Item. Compared on the platform normalization fold, consistent with the value-set rules U1/U2, so 'COLOR' and 'color' cannot coexist as two dimensions. Raw code stored and displayed as authored. SCOPE CAVEAT: 'within the tenant' presumes Attribute carries a Company reference; the tenant-scoped-dictionary-declares-company convention is currently blocked pending the company-scoping-required decision, so until that resolves the enforceable scope is whatever tenant discriminator the service layer applies. Restored 03 Sep 2026 — this rule predates the synonym work and was inadvertently dropped when businessValidation was replaced.","when":"create,update","field":"code","severity":"error"},{"rule":"H2 — name must be unique within the Attribute type, i.e. across all Attribute records in the tenant (Company). Mirrors H1 for the display-facing identifier: an operator picking a dimension in the UI, and an import addressing one by name rather than code, must both land on exactly one Attribute. The 'name' property already carries unique:true at the property level; this rule states the SCOPE that flag does not express, and the comparison basis. Compared on the platform normalization fold, consistent with H1 and with the value-set rules U1/U2 — so 'Color', 'color' and 'COLOR' are one dimension, not three. Raw name stored and displayed as authored. Note this does not catch two dimensions that merely MEAN the same thing ('Color' and 'Colour', which do not fold to the same key) — that is operator responsibility, assisted by the same non-blocking similarity advisories described in notes, applied at the Attribute level rather than the value level.","when":"create,update","field":"name","severity":"error"},{"rule":"U1 — code must be unique within the Attribute's value set, compared on the platform normalization fold (NFKD, diacritics stripped, lowercased, non-alphanumerics removed), not on the raw string. Exact-string uniqueness would admit 'Red', 'red' and 'RED ' as three distinct canonicals in one Color set; folding the comparison closes the most common way a duplicate canonical is created. The raw code is stored and displayed exactly as authored. TRADE-OFF ACCEPTED: normalized comparison also forbids pairs such as 'PS' and 'P.S.', which in an attribute value set is the desired behaviour.","when":"create,update","field":"attributeValues[].code","severity":"error"},{"rule":"U2 — name must be unique within the Attribute's value set, compared on the normalization fold, on the same basis as U1. Raw name stored and displayed as authored.","when":"create,update","field":"attributeValues[].name","severity":"error"},{"rule":"U3 — a synonym must be unique within the Attribute's value set: no two canonical values may claim the same alternate spelling. Scope is the whole value set, not the individual value — per-value uniqueness would only prevent listing the same synonym twice under one canonical, which is harmless, while leaving the case that actually breaks resolution unguarded.","when":"create,update","field":"attributeValues[].synonyms[].normalizedValue","severity":"error"},{"rule":"U4 — a synonym's normalizedValue must not equal the normalized code or name of ANY value in the set. This is not an ambiguity guard: with U1–U3 holding, resolution is deterministic and a canonical always wins. It prevents a SILENTLY DEAD MAPPING — a synonym 'l' on 'Large' never fires if another value carries code 'L', because the resolver matches code and name first, and nothing would ever tell the author their mapping is inert. Erroring at authoring time is cheaper than debugging why an import appeared to ignore a synonym months later.","when":"create,update","field":"attributeValues[].synonyms[].normalizedValue","severity":"error"},{"rule":"A synonym that duplicates its own canonical's normalized code or name is redundant — reject on create with a clear message rather than storing a no-op row.","when":"create,update","field":"attributeValues[].synonyms[].normalizedValue","severity":"error"},{"rule":"normalizedValue is derived from synonyms[].value by the platform lookup-value normalization function, immutable once written, and must never be accepted from a client payload.","when":"create,update","field":"attributeValues[].synonyms[].normalizedValue","severity":"error"}],"lifecycle":null,"calculations":[{"name":"productCount","formula":"COUNT(Product WHERE attributes CONTAINS this)","trigger":"query-time"}],"crossEntityConstraints":[{"entities":["Item","Product"],"constraint":"Resolution of an inbound attribute value string must return the canonical AttributeValue id, never create a parallel value. The raw inbound string is preserved on the Item's identifiers (with originatingSystemName) where provenance matters; it is never stored as an attribute value."},{"entities":["Attribute"],"constraint":"Because attributeValues is an embedded collection, U1–U4 cannot be expressed as a Postgres unique index on the JSON array and service-layer checks alone lose to concurrent writes on the same Attribute. Implementations must materialize a resolution-key projection — (companyId, attributeId, normalizedKey, attributeValueId, keyType: CODE|NAME|SYNONYM) with a unique index on (companyId, attributeId, normalizedKey) — rebuilt inside the same transaction as any Attribute write. One index enforces all four rules, since each is simply a duplicate normalizedKey within an attributeId. The projection is derived and fully rebuildable, never a second source of truth — same relationship as Inventory Position to Stock Ledger. It also supplies the fast path: import resolution becomes one indexed lookup instead of loading the Attribute document and scanning its JSON array per inbound row."},{"entities":["Attribute"],"constraint":"H1 and H2 are head-level rules and, unlike U1–U4, ARE expressible as real database constraints because Attribute is a row rather than an embedded array. Declare two unique indexes on the Attribute table over the normalized forms — unique(companyId, normalizedCode) and unique(companyId, normalizedName) — with the normalized columns maintained as generated/derived values from code and name. Do not rely on the property-level unique:true flag on 'name' alone: it expresses uniqueness but not the tenant scope or the normalized comparison basis, so it would permit 'Color' and 'color' as two dimensions."}]},"inlineSchemas":[{"name":"AttributeValue","desc":"A single value within an Attribute's value set, and the CANONICAL form of that value — the only spelling stored on an Item, displayed, or reported. Extends LookupEntityValue: inherits id, identifiers, code, name, aliases, synonyms, isActive, isDeleted, isDefault, sequence, audit fields, and customData. Declares no properties of its own; the canonical/synonym mechanism it depends on lives on the base schema because OptionValue and every other dictionary value set share the identical exposure. Because a combination of attribute values uniquely identifies an Item, this value set is the keying mechanism for the whole item model — a fractured value set (XL, X-Large, X Large as three canonicals) fractures the Item grain, which is what 'synonyms' exists to prevent. Inbound variant spellings are absorbed by 'synonyms' (inbound resolution keys, unique within the Attribute's value set); alternate display text belongs in 'aliases' (outbound, no cardinality rule). See the Attribute entity's businessValidation for the four uniqueness rules and the resolution algorithm.","extends":"LookupEntityValue","properties":[]}]},{"name":"Bill","class":"Transactional","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"Internal payable record generated from a matched Vendor Invoice (type 'invoice') or a matched Vendor Credit (type 'credit'). Tracks line-item costs, discounts, payment terms, and due dates against a Purchase Order. Credit-type Bills reduce the outstanding AP balance.","status":"stub","properties":[{"n":"billNo","r":true,"t":"string"},{"n":"type","r":true,"t":"string","info":"Discriminator: 'invoice' for standard payables (from Vendor Invoice), 'credit' for credit memos (from Vendor Credit). Determines sign of the AP impact."},{"n":"discount","t":"decimal"},{"n":"dueDate","t":"datetime"},{"n":"lines","r":true,"t":"array","info":"Array of BillLine sub-documents. Each line represents a charge on the bill, which may be non-inventory (services, freight, etc.)."},{"n":"location","t":"entityDetail","re":"Location"},{"n":"paymentMethod","t":"string"},{"n":"purchaseOrder","r":true,"t":"entityRef","re":"Purchase Order"},{"n":"vendorCredit","t":"entityRef","re":"Vendor Credit","info":"Source Vendor Credit when type is 'credit'. Null for invoice-type Bills."},{"n":"vendorInvoice","t":"entityRef","re":"Vendor Invoice","info":"Source Vendor Invoice when type is 'invoice'. Null for credit-type Bills."},{"n":"status","r":true,"t":"string"},{"n":"termsCode","t":"string"},{"n":"totalAmount","r":true,"t":"decimal"},{"n":"vendor","t":"entityRef","re":"Vendor"}],"ext":"TransactionalDocument","related":["Vendor","Vendor Invoice","Vendor Credit","Purchase Order","Location"]},{"name":"Brand","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Brand or manufacturer label associated with a product.","status":"stub","properties":[{"n":"brandId","r":true,"t":"string"},{"n":"code","r":true,"t":"string","u":true,"info":"Human-readable key. Unique within scope."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. A brand list is the tenant's own assortment vocabulary, not a fact about the world — two tenants carry different brands and spell shared ones differently."},{"n":"description","t":"string"},{"n":"name","r":true,"t":"string","u":true}],"ext":"LookupEntity","related":["Product"],"bv":{"rules":[],"lifecycle":null,"calculations":[{"name":"productCount","formula":"COUNT(Product WHERE brand = this)","trigger":"query-time"}],"crossEntityConstraints":[]}},{"name":"Business Entity","class":"Core","subsystem":"ALLPOINT","area":"Organization","desc":"Legal business entity within a Company — the registered legal/compliance unit for invoicing, tax, and regulatory purposes.","status":"stub","properties":[{"n":"address","t":"valueType","vt":"Address"},{"n":"businessEntityNo","r":true,"t":"string","u":true,"info":"Human-readable business identifier"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"The Company tenant this business entity belongs to. Defines the hard isolation boundary for queries, permissions, replication, and export."},{"n":"legalName","r":true,"t":"string"},{"n":"locations","r":true,"t":"array","re":"Location","info":"Locations operating under this legal entity for tax, invoicing, and regulatory purposes."},{"n":"registrationNumber","t":"string"},{"n":"taxId","t":"string"}],"notes":"A Company can have multiple Business Entities. Each Business Entity is scoped to a Company and optionally associated with specific Locations for tax and invoicing purposes.","related":["Company","Location"],"bv":{"rules":[{"rule":"Must be unique within the Company","when":"always","field":"LegalName","severity":"error"},{"rule":"Must conform to jurisdiction-specific tax ID format","when":"always","field":"TaxId","severity":"warning"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Must reference a valid active Company","entity":"Company"}]},"inheritance":{"extends":"CoreEntity"}},{"name":"Calendar Event","class":"Operational","subsystem":"CONNECT","area":"Messaging","desc":"A platform-native calendar event scoped to the Company tenant. Supports attendees with RSVP status, recurrence rules (RFC 5545 RRULE), and linking to source entities (e.g. a PO review meeting linked to a Purchase Order, a delivery window linked to a Ship Order). Franchise-group scoping controls visibility — users only see events tagged with their assigned franchise groups.","status":"draft","properties":[{"n":"attendees","r":true,"t":"array","info":"Invited users with individual response status."},{"n":"description","t":"string","info":"Event description. Supports markdown formatting."},{"n":"endAt","r":true,"t":"datetime","info":"Event end time in UTC."},{"n":"eventType","r":true,"t":"enumeration","v":["meeting","deadline","delivery","stockTake","shift","custom"],"info":"Classifies the event purpose. Used for filtering and calendar view grouping."},{"n":"isAllDay","r":true,"t":"boolean","info":"Whether this is an all-day event. When true, startAt and endAt represent date boundaries."},{"n":"isCancelled","r":true,"t":"boolean","info":"Soft-cancel flag. Cancelled events remain visible (with strikethrough) but are excluded from reminders and feeds."},{"n":"location","t":"string","info":"Free-text event location. May reference a platform Location name or an external address."},{"n":"organizer","r":true,"t":"entityRef","re":"User","info":"The User who created and owns this event. Only the organizer can edit or cancel."},{"n":"recurrenceRule","t":"string","info":"RFC 5545 RRULE string defining the recurrence pattern (e.g. FREQ=WEEKLY;BYDAY=MO;COUNT=10). Null for one-off events."},{"n":"reminderMinutes","t":"integer","info":"Minutes before the event to send a reminder notification. Null means no reminder."},{"n":"sourceEntity","t":"string","info":"Entity type this event is linked to (e.g. 'Purchase Order', 'Stock Take'). Enables contextual navigation from the calendar to the source document."},{"n":"sourceEntityId","t":"string","info":"ID of the specific entity instance this event is linked to."},{"n":"startAt","r":true,"t":"datetime","info":"Event start time in UTC."},{"n":"title","r":true,"t":"string","info":"Event title displayed in calendar views and notifications."}],"ext":"OperationalDocument","related":["User"],"bv":{"rules":[{"rule":"Must be after startAt","when":"always","field":"EndAt","severity":"error"},{"rule":"All attendees must be active Users within the same Company","when":"always","field":"Attendees","severity":"error"},{"rule":"If set, must be a valid RFC 5545 RRULE string","when":"always","field":"RecurrenceRule","severity":"error"}],"lifecycle":{"states":["Scheduled","Completed","Cancelled"],"transitions":[{"to":"Completed","from":"Scheduled","conditions":["Event endAt has passed or organizer marks complete"]},{"to":"Cancelled","from":"Scheduled","conditions":["Organizer cancels the event"]}],"initialState":"Scheduled"},"calculations":[{"name":"attendeeCount","formula":"COUNT(attendees)","trigger":"query-time"},{"name":"acceptedCount","formula":"COUNT(attendees WHERE status = 'accepted')","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Organizer and all attendees must be active Users within the same Company","entity":"User"},{"rule":"Reminder notifications may use an Email Template for delivery","entity":"Email Template"}]}},{"name":"Catalog","class":"Operational","subsystem":"CONNECT","area":"Products & Pricing","desc":"A curated subset of Products made available to specific markets or customer segments.","status":"stub","properties":[{"n":"catalogId","r":true,"t":"string"},{"n":"markets","r":true,"t":"array","re":"Market"},{"n":"name","r":true,"t":"string"},{"n":"products","r":true,"t":"array","re":"Product"}],"ext":"OperationalDocument","shopify":"Catalog resource","related":["Product","Market"],"bv":{"rules":[],"lifecycle":null,"calculations":[{"name":"productCount","formula":"COUNT(products)","trigger":"query-time"},{"name":"marketCount","formula":"COUNT(markets)","trigger":"query-time"}],"crossEntityConstraints":[]}},{"name":"Chat Channel","class":"Operational","subsystem":"CONNECT","area":"Messaging","desc":"A conversation container scoped to the Company tenant. Channels are segmented by franchiseGroups — users only see channels whose franchise groups overlap with their own assignments. Supports direct (1:1), group, and broadcast channel types.","status":"draft","properties":[{"n":"channelNo","r":true,"t":"integer","info":"Auto-incrementing channel number within the Company."},{"n":"isArchived","r":true,"t":"boolean","info":"Soft-archive flag. Archived channels are hidden from default views but data is retained."},{"n":"name","r":true,"t":"string","info":"Display name for the channel. For direct channels, typically auto-generated from participant names."},{"n":"participants","r":true,"t":"array","re":"User","info":"Users who can view and post in this channel. For broadcast channels, only admins can post."},{"n":"pinnedMessages","r":true,"t":"array","re":"Chat Message","info":"Messages pinned for quick reference. Ordered by pin date descending."},{"n":"topic","t":"string","info":"Channel topic or purpose description. Displayed in channel header."},{"n":"type","r":true,"t":"enumeration","v":["direct","group","broadcast"],"info":"Channel type. direct = 1:1 conversation; group = multi-party; broadcast = admin-post-only announcements."}],"ext":"OperationalDocument","related":["User","Chat Message"],"bv":{"rules":[{"rule":"Direct channels must have exactly two participants","when":"create","field":"Participants","severity":"error"},{"rule":"Group channels must have at least two participants","when":"create","field":"Participants","severity":"error"},{"rule":"Must be unique within the Company for group and broadcast channels","when":"always","field":"Name","severity":"error"}],"lifecycle":{"states":["Active","Archived"],"transitions":[{"to":"Archived","from":"Active","conditions":["Manual archive by channel admin or system policy"]},{"to":"Active","from":"Archived","conditions":["Manual unarchive by channel admin"]}],"initialState":"Active"},"calculations":[{"name":"participantCount","formula":"COUNT(participants)","trigger":"query-time"},{"name":"messageCount","formula":"COUNT(Chat Message WHERE channelId = this)","trigger":"query-time"},{"name":"pinnedMessageCount","formula":"COUNT(pinnedMessages)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"All participants must be active Users within the same Company","entity":"User"},{"rule":"Messages inherit tenant scope from their parent channel","entity":"Chat Message"}]}},{"name":"Chat Message","class":"Transactional","subsystem":"CONNECT","area":"Messaging","desc":"An individual message within a Chat Channel. Supports threaded replies, @mentions, emoji reactions, file attachments, and pinning. Inherits tenant isolation from its parent channel — no franchiseGroups or idmpKey on the message itself.","status":"draft","properties":[{"n":"attachments","r":true,"t":"array","info":"File attachments on this message."},{"n":"body","r":true,"t":"string","info":"Message content. Supports markdown formatting."},{"n":"channelId","r":true,"t":"entityRef","re":"Chat Channel","info":"The channel this message belongs to. Drives tenant isolation — the channel's companyId and franchiseGroups scope access."},{"n":"editedAt","t":"datetime","info":"Timestamp of last edit. Null if never edited."},{"n":"isEdited","r":true,"t":"boolean","info":"Whether the message body has been modified after sending."},{"n":"isPinned","r":true,"t":"boolean","info":"Whether this message is pinned in the channel for quick reference."},{"n":"mentions","r":true,"t":"array","re":"User","info":"Users @mentioned in this message. Drives notification delivery."},{"n":"messageType","r":true,"t":"enumeration","v":["text","system","attachment"],"info":"Message classification. text = user-authored; system = auto-generated (join/leave/archive); attachment = file-only message."},{"n":"reactions","r":true,"t":"array","info":"Emoji reactions aggregated by emoji. Each entry contains the emoji code and the list of Users who reacted."},{"n":"sender","r":true,"t":"entityRef","re":"User","info":"The User who sent this message."},{"n":"sentAt","r":true,"t":"datetime","info":"When the message was sent. Distinct from createdDate to support offline/queued message delivery."},{"n":"threadParentId","t":"entityRef","re":"Chat Message","info":"Reference to the parent message for threaded replies. Null for top-level messages."}],"ext":"OperationalSubDocument","related":["Chat Channel","User"],"bv":{"rules":[{"rule":"Must not be empty for text and system message types","when":"create","field":"Body","severity":"error"},{"rule":"Must have at least one attachment when messageType is 'attachment'","when":"create","field":"Attachments","severity":"error"},{"rule":"If set, must reference a message in the same channel","when":"always","field":"ThreadParentId","severity":"error"},{"rule":"Must be an active participant of the channel","when":"create","field":"Sender","severity":"error"}],"lifecycle":{"states":["Active","Edited","Deleted"],"transitions":[{"to":"Edited","from":"Active","conditions":["Sender modifies the message body"]},{"to":"Edited","from":"Edited","conditions":["Sender modifies the message body again"]},{"to":"Deleted","from":"Active","conditions":["Sender or channel admin deletes the message"]},{"to":"Deleted","from":"Edited","conditions":["Sender or channel admin deletes the message"]}],"initialState":"Active"},"calculations":[{"name":"reactionCount","formula":"SUM(reactions[].users.length)","trigger":"query-time"},{"name":"replyCount","formula":"COUNT(Chat Message WHERE threadParentId = this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Message must belong to a valid, non-deleted channel","entity":"Chat Channel"},{"rule":"Sender must be a participant of the channel. Mentioned users must be in the same Company.","entity":"User"}]}},{"name":"Classification","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Merchandise hierarchy: Department → Class → Subclass. Carries default tax code and weeks of supply.","status":"draft","properties":[{"n":"class","t":"entityDetail","re":"Classification Class"},{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. A merchandise hierarchy is the defining expression of how one retailer organizes its buy."},{"n":"defaultTaxClass","t":"entityDetail","re":"Tax Class"},{"n":"defaultWeeksOfSupply","t":"integer"},{"n":"department","t":"entityDetail","re":"Classification Department"},{"n":"fullName","t":"string","info":"Calculated field. Concatenation of the department name, class name, and each subclass name (e.g. 'Apparel > Men > Outerwear')."},{"n":"name","r":true,"t":"string","u":true},{"n":"subclasses","r":true,"t":"array","re":"Classification Subclass","info":"All subclasses belonging to this classification node. Each entry is a denormalized entityDetail snapshot."}],"ext":"LookupEntity","related":["Product","Tax Class","Classification Department","Classification Class","Classification Subclass"],"bv":{"rules":[{"rule":"Must be unique within the classification level/type","when":"create","field":"Code","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Cannot delete a Classification that is referenced by Products","entity":"Product"}]}},{"name":"Classification Class","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Mid level of the merchandise hierarchy, one step below Department (e.g. Mens Shirts, Televisions). Groups related Classification Subclasses for assortment planning.","status":"stub","properties":[],"ext":"TaxonomyEntityNode","related":["Classification","Classification Department"]},{"name":"Classification Department","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Top level of the merchandise hierarchy (e.g. Apparel, Electronics, Home). Groups related Classification Classes under a single department for reporting and planning.","status":"stub","properties":[],"ext":"TaxonomyEntityNode","related":["Classification"]},{"name":"Classification Subclass","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Finest level of the merchandise hierarchy, below Class (e.g. Dress Shirts, OLED TVs). Products are assigned at the subclass level for precise categorization.","status":"stub","properties":[],"ext":"TaxonomyEntityNode","related":["Classification","Classification Class"]},{"name":"Client","class":"Core","subsystem":"ACCESS","area":"Infrastructure","desc":"A deployed platform instance — the environment boundary. The Client IS the environment. \"Acme Production\" and \"Acme Sandbox\" are two separate Clients. A Client hosts one or many Companies (tenants). Some Clients are shared infrastructure (multiple tenants), others are dedicated to a single Organization (single tenant). Each Client carries its own OAuth2/OIDC credential sets and scope grants. Auth and permissions are scoped to the Client/Company boundary — a token issued for one Client/Company cannot access resources belonging to another.","status":"draft","properties":[{"n":"clientNo","r":true,"t":"string","u":true,"info":"Human-readable business identifier"},{"n":"companies","r":true,"t":"array","re":"Company","info":"Tenants hosted within this Client"},{"n":"deploymentType","r":true,"t":"enumeration","v":["shared","dedicated"],"info":"shared = multi-tenant infrastructure, dedicated = single Organization"},{"n":"environment","r":true,"t":"entityRef","re":"Environment","info":"The Environment this Client is deployed to (e.g. dev, qa, uat, prod). NOTE: flagged by dictionary-ref-uses-entity-detail — Environment is a Dictionary-class entity, so this should arguably be entityDetail to embed code/name for display. Left as entityRef pending a decision, since changing it alters the persisted shape."},{"n":"name","r":true,"t":"string","info":"Display name (e.g., \"APR Cloud US-East Production\")"},{"n":"status","r":true,"t":"enumeration","v":["active","provisioning","maintenance","decommissioned"]}],"service":"APR Access","ext":"CoreEntity","shopify":"App/Client credentials model","notes":"Canonical subsystem is PLATFORM (Infrastructure). Currently registered under ACCESS pending subsystem reassignment — needs delete-and-recreate to move to PLATFORM. Extends CoreEntity (not BaseDocument) because Client is a control-plane entity describing deployed infrastructure rather than tenant data — it is named in the company-scoping-required exemption set, so BaseDocument's franchiseGroups would be meaningless here. The former capital-'Id' string surrogate key was removed in favour of the inherited UUID id per no-redundant-entity-id.","related":["Company","Environment","Scope"]},{"name":"Commission Plan","class":"Dictionary","subsystem":"CONNECT","area":"Organization","desc":"A named commission scheme assigned to employees via their effective-dated compensation records. Defines what commission is earned on (the basis), how it is calculated (flat, tiered or per-unit), over what accumulation period tier thresholds are measured, when it is considered earned, and how returns and split sales are handled. Modeled as shared reference data rather than per-employee values because plans are assigned across many employees and carry tiered rate structures of their own. All Point is not the system of record for payroll — commission figures computed from a plan are for labor costing, sales reporting and export to the external payroll system.","status":"draft","properties":[{"n":"accrualPeriod","r":true,"t":"enumeration","v":["PerTransaction","Daily","Weekly","BiWeekly","SemiMonthly","Monthly","Quarterly"],"info":"The window over which the basis accumulates before tier thresholds are evaluated. Required for Tiered plans and the single most common source of commission disputes when left implicit — a 5% tier at 10,000 means something very different per transaction than per quarter. Set PerTransaction for FlatRate and PerUnit plans."},{"n":"basis","r":true,"t":"enumeration","v":["GrossSales","NetSales","GrossMargin","Quantity"],"info":"What commission is calculated on. GrossSales = sales value before discounts and returns. NetSales = after discounts and returns. GrossMargin = sales value less cost, which requires a resolvable cost at time of sale. Quantity = units sold, used with a PerUnit rate type."},{"n":"calculationMethod","r":true,"t":"enumeration","v":["FlatRate","Tiered","PerUnit"],"info":"How the rate is resolved. FlatRate applies flatRate to the whole basis amount and ignores tiers. Tiered resolves the rate from the tiers array against the accumulated basis for the accrualPeriod. PerUnit applies a per-unit amount, valid only when basis is Quantity."},{"n":"code","r":true,"t":"string","u":true,"info":"Short human-readable plan identifier used in imports, API filters and UI dropdowns (e.g. FOOTWEAR-TIER, MGR-FLAT-2)."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"The Company tenant this plan belongs to. Required because Commission Plan is tenant-scoped reference data rather than a platform-global dictionary."},{"n":"description","t":"string","info":"Plain-language summary of the plan for the staff assigning it."},{"n":"effectiveFrom","r":true,"t":"date","info":"First date the plan may be applied. Plan-level effective dating is independent of the EmployeeCompensation record's own effective range; the operative window is the intersection of the two."},{"n":"effectiveTo","t":"date","info":"Last date the plan may be applied. Null means open-ended. Superseding a plan sets this rather than editing rates in place, so historical commission remains reproducible."},{"n":"flatRate","t":"decimal","info":"Commission percentage applied to the whole basis amount. Used only when calculationMethod is FlatRate; null otherwise. Expressed as a percentage (5.0 = 5%), not a fraction."},{"n":"isDiscountedSaleCommissionable","r":true,"t":"boolean","info":"Whether items sold below full price earn commission. False excludes discounted and marked-down lines from the basis entirely. Retailers commonly set this false to stop staff discounting to close sales at the company's expense."},{"n":"returnTreatment","r":true,"t":"enumeration","v":["ClawBack","ClawBackWithinPeriod","NoAdjustment"],"info":"How a returned sale affects commission already accrued. ClawBack reverses it whenever the return occurs. ClawBackWithinPeriod reverses only if the return falls inside the accrual period the sale was earned in. NoAdjustment leaves it earned. Must be settled explicitly — silent clawback after a payroll run has already paid the commission creates a negative-earnings correction in the external payroll system."},{"n":"splitMethod","r":true,"t":"enumeration","v":["PrimaryOnly","Equal","Percentage"],"info":"How commission is apportioned when more than one SalesPerson is credited on a single sale. PrimaryOnly credits the primary salesperson and nothing to the others. Equal divides evenly across all credited. Percentage uses per-line split percentages captured on the Sale."},{"n":"tiers","r":true,"t":"array","info":"Array of CommissionTier sub-documents defining threshold bands and their rates. Empty array for FlatRate and PerUnit plans. For Tiered plans the bands must be contiguous and non-overlapping, and the top band should be open-ended (null thresholdTo)."}],"service":"APR Connect","ext":"LookupEntity","related":["Employee","Company","Sale","Sales Order"],"inlineSchemas":[{"name":"CommissionTier","extends":"LookupEntityValue","properties":[{"info":"The commission rate for this band. Interpret against rateType — a percentage (5.0 = 5%) for Percentage, or a monetary amount in the Company base currency for PerUnit and FixedAmount.","name":"rate","type":"decimal","required":true},{"info":"How rate is applied within this band. Percentage applies to the band's portion of the basis. PerUnit multiplies by units sold. FixedAmount pays a flat sum for reaching the band.","name":"rateType","type":"enumeration","values":["Percentage","PerUnit","FixedAmount"],"required":true},{"info":"Whether the rate applies only to the portion of the basis falling inside this band (marginal, like tax brackets) or retroactively to the entire basis once the band is reached (cliff). Cliff tiers pay dramatically more at the threshold and must be a deliberate choice, not an accident of implementation.","name":"isMarginal","type":"boolean","required":true},{"info":"Lower bound of the band, measured in the plan's basis and accumulated over its accrualPeriod. The first band starts at 0.","name":"thresholdFrom","type":"decimal","required":true},{"info":"Upper bound of the band. Null marks the open-ended top band. Bands must be contiguous with no gaps or overlaps.","name":"thresholdTo","type":"decimal"}]}]},{"name":"Company","class":"Core","subsystem":"ALLPOINT","area":"Organization","desc":"The tenant boundary in the platform's multi-tenant architecture. All data — products, inventory, sales, locations, pricing, users, and access control — is isolated within a Company. Each Company belongs to an Organization and scopes commerce operations end-to-end. Each Company lives within a Client (the deployed instance). The Company's deploymentModel (Shared / Dedicated) determines whether the Company shares its Client with other tenants or receives an isolated Client — tenancy is enforced at the Company level, not at the Organization level. Dedicated deployment is gated by the owning Organization's Subscription.deploymentEntitlement, since infrastructure isolation is bought once for the subscriber rather than per product. LICENSING IS NOT A COMPANY PROPERTY: a licence is held per installed instance — Application Installation.licensing and Connection.licensing, each carrying a LicenseGrant naming a Licensing Tier — because a Company routinely runs several Applications on different plan levels. A Company.tier enumeration was previously declared here and was removed on 16 Sep 2026; it was never implemented, so no data migrates from it. Key capability — Franchise Group sub-tenancy: within the Company boundary, data is soft-partitioned into sub-tenants (Franchise Groups) so multiple independently owned franchise operators share one tenant while each sees only its own data plus GLOBAL-scoped records. Sanitizable documents carry a franchiseGroups array, Users carry group assignments, and reads are filtered to overlapping groups plus GLOBAL (Data Sanitization). Company enforces hard isolation; Franchise Group enforces soft, user-scoped visibility filtering within it — sub-tenancy never crosses Companies. Formal capability definition: connect-franchise-group-subtenancy-capability.md (FGT-R1–R12).","status":"draft","properties":[{"c":true,"n":"applicationCount","t":"integer","info":"Query-time count of Application entities belonging to this Company. Formula: COUNT(Application WHERE company = this). Computed; not stored."},{"n":"client","r":true,"t":"entityRef","re":"Client","info":"The Client (deployed instance) this Company lives within"},{"n":"companyNo","r":true,"t":"string","u":true,"info":"Human-readable business identifier"},{"n":"configurations","r":true,"t":"array","info":"Array of CompanyConfiguration inline schema objects. Each entry binds one Config record (via configurationId → Config in the CONFIG subsystem) to an area label (Branding, Merchandising, Commerce, Integrations, etc.), allowing a Company's settings to be composed from multiple purpose-scoped Config records rather than a single monolithic Config. area should be unique within this array — at most one Config per area per Company."},{"n":"currency","r":true,"t":"entityDetail","re":"Currency","info":"Tenant-default currency for this Company. All monetary values within the Company boundary (pricing, costs, ledger entries) resolve to this currency unless explicitly overridden by Market, Sales Channel, or transaction-level rules. Modeled as entityDetail (denormalized code/name/alias/sequence) for Company isolation."},{"n":"defaultCostingMethod","r":true,"t":"enumeration","v":["FIFO","LIFO","WAC"],"info":"Tenant-default inventory costing method. FIFO is the platform default per the Stock Ledger Service PRD. Individual item × location pairs may override this via Item Stock.costingMethod; if not overridden, they inherit this value. Changing this after go-live only affects new item-locations; existing positions retain whatever method they were last configured with until an offline replay job is run."},{"n":"deploymentModel","r":true,"t":"enumeration","v":["Shared","Dedicated"],"info":"Shared: this Company runs on multi-tenant infrastructure (a Client hosting multiple tenants). Dedicated: this Company receives an isolated environment (compute, databases, cache) — a Client dedicated to a single tenant. Tenancy is enforced at the Company level (the tenant boundary), so deploymentModel is a Company property, not an Organization property. Infrastructure details are managed as Config Service configurations, not as entity properties. COMMERCIAL GATING: Dedicated requires the owning Organization's active Subscription to carry deploymentEntitlement 'dedicated'. Infrastructure isolation is bought once, account-wide, so the entitlement sits on the contract while the provisioning decision stays here — different Companies under one Organization may legitimately sit on different Clients with different deployment models. This replaces the earlier wording that Dedicated was 'provisioned at an increased licensing tier', which pointed at a Company.tier property that has since been removed (it was never implemented) and which conflated infrastructure pricing with per-application entitlement. The two are now separate layers: Subscription.deploymentEntitlement for infrastructure, LicenseGrant.tier for product entitlement."},{"n":"locale","r":true,"t":"entityDetail","re":"Locale","info":"Tenant-default locale (BCP 47 language-region tag, e.g. en-US, fr-CA) controlling number/date/currency formatting and the default content language for storefront and back-office UI. Modeled as entityDetail (denormalized code/name/alias/sequence) for Company isolation. May be overridden at Market or Sales Channel level."},{"n":"name","r":true,"t":"string"},{"n":"organization","r":true,"t":"entityRef","re":"Organization","info":"Many-to-one. Each Company belongs to exactly one Organization."},{"n":"status","r":true,"t":"schema","info":"CompanyStatus inline schema capturing current status with audit trail."},{"n":"timezone","r":true,"t":"string","info":"IANA timezone identifier (e.g. 'America/New_York', 'Europe/London') used as the Company's default for fiscal-date calculations, scheduled jobs, business-hours logic, and any datetime presentation that doesn't carry its own location-level timezone override. Locations may set their own timezone for store-level operations."},{"c":true,"n":"userCount","t":"integer","info":"Query-time count of User entities belonging to this Company. Formula: COUNT(User WHERE company = this). Computed; not stored."}],"related":["Organization","Client","Location","Market","Business Entity","Franchise Group","Application Installation","Connection","Subscription"],"bv":{"rules":[{"rule":"Must be unique across the platform","when":"create","field":"Name","severity":"error"},{"rule":"Must reference a valid Currency dictionary entry","when":"create","field":"currency","severity":"error"},{"rule":"Must be a valid IANA timezone identifier (e.g. 'America/New_York')","when":"create","field":"timezone","severity":"error"},{"rule":"Must reference a valid Locale dictionary entry (BCP 47 tag, e.g. en-US)","when":"create","field":"locale","severity":"error"}],"lifecycle":null,"calculations":[{"name":"userCount","formula":"COUNT(User WHERE company = this)","trigger":"query-time"},{"name":"applicationCount","formula":"COUNT(Application WHERE company = this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Cannot delete a Company that has active Locations","entity":"Location"},{"rule":"At least one Market must exist before Commerce operations can begin","entity":"Market"}]},"inlineSchemas":[{"name":"CompanyStatus","properties":[{"n":"status","r":true,"t":"enumeration","v":["Draft","Active","Archived"],"info":"Current status of the company."},{"n":"changedBy","t":"string","info":"User who last changed the status."},{"n":"changedDate","t":"datetime","info":"Timestamp of the last status change."}]},{"name":"CompanyConfiguration","info":"One scoped Config binding for a Company. Pairs a configurationId (reference to a Config record in the CONFIG subsystem) with an area label indicating which domain of settings this Config holds — allowing a Company to compose its settings from multiple purpose-scoped Config records rather than a single monolithic one.","properties":[{"n":"area","r":true,"t":"enumeration","v":["Branding","Merchandising","Commerce","Operations","Features","Localization","Security"],"info":"The domain / area of configuration this Config record is scoped for. Each area corresponds to a purpose-specific Config Template (e.g. Branding = logos, colors, typography; Merchandising = catalog defaults, classification rules; Integrations = connector defaults, auth defaults). Enumeration is extensible — new areas are added as new Config Templates come online."},{"n":"configurationId","r":true,"t":"entityRef","re":"Config","info":"Reference to the Config record in the CONFIG subsystem that holds the settings for this area. Resolved against Config Templates registered by the owning service."}]}],"inheritance":{"extends":"CoreEntity"}},{"name":"Config","class":"Core","subsystem":"CONFIG","area":"Configuration","desc":"A resolved configuration instance for a given Actor and Environment. Derived from a Config Template with overrides applied.","status":"stub","properties":[{"n":"actor","r":true,"t":"entityRef","re":"Actor"},{"n":"configId","r":true,"t":"string"},{"n":"environment","r":true,"t":"entityDetail","re":"Environment"},{"n":"template","r":true,"t":"entityRef","re":"Config Template"},{"n":"values","r":true,"t":"schema"},{"c":true,"n":"version","r":true,"t":"integer"}],"service":"APR Config","shopify":"Shop resource settings / metafield-based config","related":["Config Template","Actor","Environment"],"bv":{"rules":[{"rule":"Must validate against the associated Config Template schema","when":"always","field":"Value","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Must reference a valid Config Template","entity":"Config Template"},{"rule":"Values may be overridden per Environment","entity":"Environment"}]}},{"name":"Config Template","class":"Core","subsystem":"CONFIG","area":"Configuration","desc":"A reusable schema defining the shape and defaults of a Config. Services register expected config structure via templates.","status":"draft","properties":[{"n":"actor","t":"entityRef","re":"Actor"},{"n":"defaultValues","t":"schema"},{"n":"documentType","t":"entityDetail","re":"Document Type"},{"n":"name","r":true,"t":"string"},{"n":"schema","r":true,"t":"schema"},{"n":"templateId","r":true,"t":"string"}],"service":"APR Config","related":["Config","Actor","Document Type"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Templates define the schema that Config instances must conform to","entity":"Config"}]}},{"name":"Connection","class":"Core","subsystem":"CONNECT","area":"Integration","desc":"A Company-scoped configuration that pairs a source Connector with a destination Connector, holds the authenticated access each side operates under, and carries the transformation rules, sync mode and workflow logic governing how data moves between them. Connection is to Connector what Application Installation is to Application: the installed, credentialed, tenant-scoped instance of a global-registry blueprint. Credentials are held per side (sourceCredentials, destinationCredentials) because the two ends authenticate against different systems independently — one side can be connected while the other sits at pending_auth or has expired. The Connection therefore defines WHAT flows (canonical entities in scope, direction), HOW (schedule, field mappings, conflict resolution, error policy) and UNDER WHAT ACCESS, with no reference to an Application Installation.","status":"draft","properties":[{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"The Company tenant this connection belongs to. Defines the hard isolation boundary for queries, permissions, replication, and export."},{"n":"connectionNo","r":true,"t":"string","u":true,"info":"Human-readable business identifier"},{"n":"destinationConnector","r":true,"t":"entityRef","re":"Connector","info":"Destination integration blueprint (from global registry)"},{"n":"destinationCredentials","r":true,"t":"schema","info":"ConnectionCredentials sub-document holding the authenticated access for the destination side — the credentials the destinationConnector operates under within this Connection. Required as a container so authStatus always has a home; the credentials inside it stay empty until authorization completes."},{"n":"direction","r":true,"t":"enumeration","v":["unidirectional","bidirectional"]},{"n":"errorPolicy","t":"schema","info":"Retry config, dead-letter behavior, alert thresholds"},{"n":"lastSyncAt","t":"datetime","info":"Last successful sync timestamp"},{"n":"lastSyncStatus","t":"enumeration","v":["success","partial","failed"]},{"n":"licensing","r":true,"t":"valueType","info":"LicenseGrant value type holding the licence this Connection operates under — the tier granted, the contract funding it, its window, and any negotiated overrides. Required as a container so licenseStatus always has a home, on the same reasoning as the per-side credentials sub-documents. A Connection pairs two Connectors, so the grant's tier must belong to one of them; where source and destination differ, the licensed side is whichever connector the tier names. Single-grant rather than per-side by design: a Connection is bought as one integration, unlike credentials, which genuinely authenticate independently against two systems."},{"n":"name","r":true,"t":"string","info":"Operator-facing label (e.g., Fulcrum X → Shopify Product Sync)"},{"n":"scopedEntities","r":true,"t":"array","info":"Canonical schema entities this Connection covers. Renamed from entityScope for the plural-array convention, and aligned with Connector.supportedEntities."},{"n":"sourceConnector","r":true,"t":"entityRef","re":"Connector","info":"Source integration blueprint (from global registry)"},{"n":"sourceCredentials","r":true,"t":"schema","info":"ConnectionCredentials sub-document holding the authenticated access for the source side — the credentials the sourceConnector operates under within this Connection. Required as a container so authStatus always has a home; the credentials inside it stay empty until authorization completes."},{"n":"status","r":true,"t":"enumeration","v":["draft","active","paused","archived"],"info":"OPERATOR INTENT — what a human wants this Connection to do. Set only by a user, never by the system. Distinct from observed system state, which lives in the per-side sourceCredentials.authStatus / destinationCredentials.authStatus and healthStatus, and in lastSyncStatus. The two are independent and must not be derived from each other: pausing a Connection does not change either side's authStatus, and an expired token does not move status off active. Effective syncability is the conjunction — status is active AND both sides' authStatus is connected. An active Connection with an unauthorized side is BLOCKED, not paused, and should be surfaced to the operator as such rather than silently reported as running. The former 'error' value was removed: no operator sets a Connection to error, and system state now lives in authStatus / healthStatus / lastSyncStatus."},{"n":"syncMode","r":true,"t":"enumeration","v":["realtime","scheduled","manual"]},{"n":"syncSchedule","t":"string","info":"Cron expression (if scheduled)"},{"n":"workflowRules","t":"schema","info":"Transformation rules, field mappings, filters, conflict resolution"}],"ext":"BaseDocument","notes":"Extends BaseDocument because Connection is genuinely Company-scoped tenant data, so the inherited franchiseGroups is meaningful for sub-tenant segmentation. Properties dropped on recreate: the capital-'Id' string surrogate key (per no-redundant-entity-id, superseded by the inherited UUID id), and the locally-declared createdBy and createdAt, which duplicated BaseDocument's createdBy and createdDate — createdBy would have been a hard duplicate-property-name error. If a typed User reference is needed for the operator who configured the Connection, add it as configuredBy rather than shadowing the audit field. sourceInstallation and destinationInstallation (entityRefs to Application Installation) were removed: a Connection pairs Connectors only, and the Connection itself is the credential holder for connector-based integrations — Connection : Connector :: Application Installation : Application. Credentials therefore live in the per-side ConnectionCredentials sub-documents. NOTE ON DUPLICATION: ConnectionCredentials deliberately mirrors Application Installation's field names (credentials, externalAccountId, externalAccountUrl, healthStatus, lastHealthCheckAt, and status as authStatus) because the two hold the same kind of state for two different blueprint types. If a third credential holder appears, extract these into a shared IntegrationCredentials value type rather than copying the field set a third time.","related":["Company","Connector","Licensing Tier","Subscription"],"bv":{"rules":[{"rule":"status is operator intent and is writable only by a user. No system process (sync runner, health check, token refresh, error handler, billing) may write to status. System-observed state is reported via sourceCredentials.authStatus, destinationCredentials.authStatus, healthStatus on either side, lastSyncStatus, and licensing.licenseStatus. The converse also holds: those properties are written only by the system and never by an operator.","when":"always","field":"status","severity":"error"},{"rule":"Effective syncability is the conjunction of intent, connectivity and entitlement: a sync may execute only when status is 'active' AND sourceCredentials.authStatus is 'connected' AND destinationCredentials.authStatus is 'connected' AND licensing.licenseStatus is 'active' or 'trial'. Each failure must be reported as itself. status not 'active' = PAUSED (an operator stopped it). status 'active' with either side unauthorized = BLOCKED (nobody stopped it; someone must re-authenticate that side). status 'active', both sides connected, licenseStatus not licensed = UNLICENSED (nothing is broken; someone must buy or renew). Reporting an unlicensed Connection as paused is the same misattribution error the paused/blocked distinction was introduced to prevent.","when":"sync is attempted","field":"status","severity":"error"},{"rule":"licensing.licenseStatus is system-observed and writable only by the billing system. Pausing a Connection must not change licenseStatus, and a lapsed licence must not move status off 'active'.","when":"always","field":"licensing","severity":"error"},{"rule":"licensing.tier must reference a Licensing Tier whose connector is either this Connection's sourceConnector or its destinationConnector. Where the two differ, the licensed side is whichever connector the tier names — a Connection is bought as one integration and carries one grant, unlike credentials, which genuinely authenticate independently against two systems.","when":"create","field":"licensing","severity":"error"},{"rule":"A grant does not resolve as licensed if its funding Subscription is not status 'active', regardless of what licenseStatus currently reads.","when":"sync is attempted","field":"licensing","severity":"error"},{"rule":"MIGRATION: 'error' was removed from the status enum and 'suspended' from ConnectionCredentials.authStatus. Existing records carrying status='error' must be remapped — to 'active' where the Connection is still intended to run (the error is then reflected in authStatus/lastSyncStatus), or to 'paused' where an operator had in effect stopped it. The two cannot be distinguished automatically and need an operator decision or a per-record review. Records carrying authStatus='suspended' map to authStatus='disconnected' with status='paused'.","when":"migration","field":"status","severity":"warning"},{"rule":"MIGRATION (licensing, 15 Sep 2026, revised 16 Sep 2026): licensing is required, so every existing Connection needs a grant backfilled, and there is no predecessor to derive it from — the Company.tier property this model replaces was never implemented, and in any case a tenant-wide value would have said nothing about which integrations were sold. All existing Connections backfill to licenseStatus 'unlicensed' and must be reconciled against contracts before enforcement is switched on. Enforce in report-only mode for one billing period first; switching straight to hard enforcement on an unreconciled backfill would stop live syncs for paying customers.","when":"migration","field":"licensing","severity":"warning"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"licensing.tier must belong to one of this Connection's connectors. Deactivating a tier blocks new grants only and must not invalidate Connections already granted it.","entity":"Licensing Tier"},{"rule":"The grant's funding Subscription must belong to the Organization that owns this Connection's Company.","entity":"Subscription"}]},"inlineSchemas":[{"name":"ConnectionCredentials","extends":"OperationalSubDocument","properties":[{"info":"SYSTEM-OBSERVED STATE — the actual authorization state of this side, written only by the system (auth flow, token refresh, health check, sync runner). Never set by an operator. The former 'suspended' value was removed for the same reason 'error' was removed from Connection.status, in the opposite direction: suspending is an operator intent and belongs in Connection.status, not in an observed-state enum. A Connection may be fully configured (entities, mappings, schedule) while one or both sides sit at pending_auth.","name":"authStatus","type":"enumeration","values":["pending_auth","connected","error","disconnected"],"required":true},{"info":"When authorization was last granted or refreshed for this side.","name":"authorizedAt","type":"datetime"},{"info":"User who completed the authorization for this side.","name":"authorizedBy","type":"entityRef","relatedEntity":"User"},{"info":"OAuth tokens, API keys and secrets for this side of the Connection, encrypted at rest. Deliberately NOT required: the sub-document exists from the moment the Connection is created so authStatus has somewhere to live, and stays empty while authStatus is pending_auth. Contrast Application Installation.credentials, which is required because that record is created as part of an install that has already authenticated.","name":"credentials","type":"encrypted"},{"info":"Account, store or tenant identifier on the external system for this side (e.g. Shopify store ID, NetSuite account ID). Issued externally, so exempt from no-redundant-entity-id. Field name deliberately matches Application Installation.externalAccountId.","name":"externalAccountId","type":"string"},{"info":"URL of the account on the external system for this side (e.g. acme.myshopify.com).","name":"externalAccountUrl","type":"string"},{"info":"Current health of this side's authenticated access, independent of the Connection's own lastSyncStatus — credentials can be failing before any sync is attempted.","name":"healthStatus","type":"enumeration","values":["healthy","degraded","failing","unknown"]},{"info":"Last time this side's access was verified.","name":"lastHealthCheckAt","type":"datetime"},{"info":"Expiry of the current access token, where the provider issues one. Drives proactive refresh ahead of a scheduled sync rather than discovering expiry as a sync failure.","name":"tokenExpiresAt","type":"datetime"}]}]},{"name":"Connector","class":"Core","subsystem":"CONNECT","area":"Organization","desc":"A source or destination integration blueprint that defines the data exchange capabilities for a system. Each Connector describes what canonical schema entities it supports, what directions it can operate in, and what sync capabilities it has. Connectors exist once in a global registry, independently of Applications — there is no reference between the two, and a Connector is not owned by or derived from any Application. The Connector defines what data can flow; authenticated access is supplied separately by an Application Installation, and the two are brought together only on a Connection, which pairs a Connector with an Application Installation per side.","status":"draft","properties":[{"n":"configuration","r":true,"t":"valueType","info":"Reference to this Connector's configuration record in the CONFIG subsystem. Uses the ConfigurationRef value type (config: entityDetail → Config, templateCode: string). Holds connector-level defaults for supported entities, sync cadence, and capability toggles — distinct from per-Connection runtime settings."},{"n":"connectorNo","r":true,"t":"string","u":true,"info":"Human-readable business identifier"},{"n":"description","t":"string","info":"What this Connector integrates with"},{"n":"name","r":true,"t":"string","u":true,"info":"Display name (e.g., Shopify, Fulcrum X). Globally unique"},{"n":"slug","r":true,"t":"string","u":true,"info":"Human-readable key (e.g., shopify, fulcrum-x, netsuite). Globally unique. Redeclared from CoreEntity to tighten the inherited slug to required and unique."},{"n":"supportedDirections","r":true,"t":"array","info":"source, destination, or both"},{"n":"supportedEntities","r":true,"t":"array","info":"Canonical schema entities this Connector can source/sink (e.g., Product, Order, Customer)"},{"n":"type","r":true,"t":"enumeration","v":["first_party","commerce_platform","erp","accounting","marketing","loyalty","wms","3pl","shipping","marketplace"],"info":"Integration category"}],"ext":"CoreEntity","notes":"Extends CoreEntity (not BaseDocument) because Connector is a global-registry blueprint that exists once across all tenants, so BaseDocument's franchiseGroups would be meaningless; CoreEntity also supplies the slug field this entity needs. The former capital-'Id' string surrogate key was removed in favour of the inherited UUID id per no-redundant-entity-id. The former applicationId property (an entityRef to Application) was REMOVED as incorrect: Connector and Application are independent concepts with no direct relationship. Any apparent pairing between them is an artifact of a specific Connection, which references a Connector and an Application Installation separately per side — do not reintroduce a Connector → Application reference.","related":["Connection"]},{"name":"Content Page","class":"Operational","subsystem":"CONNECT","area":"Content","desc":"Evergreen, navigable internal reference content — an operations manual section, a brand standards page, an onboarding guide, a policy reference. Nests into a page tree via parentPage and is addressed by a stable slug, which is what separates it from Content Post: a Post is found by scrolling a feed and ages out, a Page is found by navigating or searching and is expected to stay current indefinitely. The inherited scheduling window still applies but is normally unused (publishAt null, expireAt null); it exists so that a policy page can be prepared ahead of an effective date and published automatically. Deliberately NOT mapped to a Shopify Page: a Shopify Page is customer-facing storefront content that syncs outward, while a Content Page is internal to the franchise network. That collision is the reason the entity is named Content Page rather than Page.","status":"draft","properties":[{"n":"contentPageNo","r":true,"t":"string","u":true,"info":"Human-readable identifier, unique within Company. Per entity-no-property convention."},{"n":"isNavigationVisible","r":true,"t":"boolean","info":"When true, the page appears in the navigation tree for its audience. When false, the page is reachable only by direct link or search — used for deep reference material that would clutter navigation. Not an access control: a hidden page is still governed by the inherited audience. Per boolean-must-be-required."},{"n":"parentPage","t":"entityRef","re":"Content Page","info":"Parent page in the navigation tree. Null = top-level page. Self-referential; cycles must be rejected on write. Traversal is recursive — Content Page does not extend TaxonomyEntity and so carries no materialized path or depth."},{"n":"sequence","r":true,"t":"integer","info":"Ordering position among sibling pages under the same parentPage. Lower sorts first. Ties break on title."},{"n":"slug","r":true,"t":"string","u":true,"info":"URL-safe stable identifier, unique within Company. Used for direct linking and deep links from Announcements and Content Posts. Immutable once the page has been published, because published slugs are shared in links and chat messages that would otherwise break."}],"ext":"ContentDocument","notes":"PROPOSED — not yet reviewed. Created 31 Aug 2026 as part of the Content module drafting, alongside Content Post and the ContentDocument base schema.\n\nparentPage (entityRef -> Content Page, self-referential) is added in a follow-up call because the target entity must exist in the registry before an entityRef to it validates.\n\nThe page tree is modelled as a self-reference rather than by extending TaxonomyEntity, because Content Page needs the full ContentDocument publishing lifecycle and the registry is single-inheritance. This means Content Page gets no materialized path, depth or ancestorIds — tree traversal is recursive on parentPage. Acceptable for an operations manual measured in hundreds of pages; revisit if it becomes thousands. Note this is a DIFFERENT tree from Media Folder, which does extend TaxonomyEntity. Two trees, two mechanisms — worth being deliberate about rather than discovering later.\n\nOpen questions: (1) versioning — a policy page that changes needs a history of what was effective when, which the current model does not provide (status transitions are not versions); this matters more for Page than for Post or Announcement. (2) should a published Page support a draft revision alongside the live version, or does editing a published page take it out of publication? (3) slug uniqueness is declared within Company — confirm it should not be within parentPage instead, which would allow the same leaf slug under different sections.","related":["User","Location","Scope","Media","Media Folder"],"bv":{"rules":[{"rule":"Immutable once status = 'published' — published slugs appear in shared links and must not break","when":"update","field":"Slug","severity":"error"},{"rule":"Must be URL-safe: lowercase alphanumerics and hyphens only","when":"always","field":"Slug","severity":"error"},{"rule":"If set, must be > publishAt","when":"always","field":"ExpireAt","severity":"error"},{"rule":"Immutable once status = 'published'","when":"update","field":"PublishedAt","severity":"error"}],"lifecycle":{"states":["Draft","Scheduled","Published","Expired","Archived"],"transitions":[{"to":"Scheduled","from":"Draft","conditions":["publishAt is set and >= now()"]},{"to":"Published","from":"Draft","conditions":["Author publishes immediately"]},{"to":"Published","from":"Scheduled","conditions":["publishAt reached; system publishes"]},{"to":"Draft","from":"Scheduled","conditions":["Author cancels scheduled publish"]},{"to":"Expired","from":"Published","conditions":["expireAt reached — unusual for a Page, which is normally evergreen"]},{"to":"Archived","from":"Published","conditions":["Admin manually archives"]},{"to":"Archived","from":"Expired","conditions":["Admin manually archives"]}],"initialState":"Draft"},"calculations":[],"crossEntityConstraints":[{"rule":"Archiving a page whose slug is referenced by a published Announcement or Content Post deep link requires confirmation","entity":"Announcement"},{"rule":"Inherited attachments and heroMedia must reference Media records whose visibility is compatible with the page's resolved audience","entity":"Media"},{"rule":"All targetLocations must belong to the same Company as this page","entity":"Location"}]}},{"name":"Content Post","class":"Operational","subsystem":"CONNECT","area":"Content","desc":"A dated, chronological internal post published to the Feed surface — franchisor news, a product launch note, a store spotlight, a training write-up. Distinguished from Announcement by what it does NOT do: no acknowledgement requirement, no priority escalation, no multi-channel fan-out, no preference bypass. A Post is pull content that appears in a feed the reader chooses to open; an Announcement is push content that arrives whether or not they do. That distinction is the whole reason both exist, and it is why Content Post declares almost no properties of its own — the authoring shape, publishing lifecycle and audience targeting all come from ContentDocument. Deliberately NOT mapped to a Shopify Article: a Shopify Article is customer-facing storefront content that syncs outward, while a Content Post is internal to the franchise network and never leaves the platform.","status":"draft","properties":[{"n":"contentPostNo","r":true,"t":"string","u":true,"info":"Human-readable identifier, unique within Company. Per entity-no-property convention."},{"n":"isFeatured","r":true,"t":"boolean","info":"When true, the post is promoted to the feed hero slot for its audience. Distinct from the inherited isPinned, which holds position at the top of the chronological list — a post can be pinned without being featured and vice versa. Per boolean-must-be-required."},{"c":true,"n":"readTimeMinutes","r":true,"t":"integer","info":"Calculated estimate derived from body word count at a fixed 200 words/minute, floored at 1. Display aid on feed rows. Query-time."}],"ext":"ContentDocument","notes":"PROPOSED — not yet reviewed. Created 31 Aug 2026 as part of the Content module drafting, alongside Content Page and the ContentDocument base schema.\n\nDeliberately thin. Three own properties against fourteen inherited from ContentDocument — that ratio IS the design outcome, not an omission. If Content Post starts accumulating scheduling, targeting or lifecycle properties of its own, the base schema has been bypassed and the duplication ContentDocument exists to prevent has returned.\n\nExplicitly NOT modelled here: comments and reactions. Both are plausible feed features and neither has an entity in the registry. Adding a boolean like areCommentsEnabled would imply a Comment entity that does not exist — see the open question logged on this.\n\nNo shopify_equivalent is set, deliberately. See the description.\n\nOpen questions: (1) comments/reactions — do they get entities, and if so does Chat Message's reactions shape generalize? (2) does a Post need the viewCount rollup Announcement gets from Announcement Acknowledgement, and if so does it need its own ledger or a lighter view-tracking mechanism? (3) categories — Announcement has a category enum driving surface treatment; a Post currently relies on inherited tags.","related":["User","Location","Scope","Media","Media Folder","Announcement"],"bv":{"rules":[{"rule":"Must be >= now() on create or transition to 'scheduled'","when":"create","field":"PublishAt","severity":"error"},{"rule":"If set, must be > publishAt","when":"always","field":"ExpireAt","severity":"error"},{"rule":"Immutable once status = 'published'","when":"update","field":"PublishedAt","severity":"error"},{"rule":"A featured post must also be published — draft and scheduled posts cannot be featured","when":"always","field":"IsFeatured","severity":"error"},{"rule":"At most one Content Post may be featured per franchise group at a time; featuring a second demotes the first","when":"update","field":"IsFeatured","severity":"warning"}],"lifecycle":{"states":["Draft","Scheduled","Published","Expired","Archived"],"transitions":[{"to":"Scheduled","from":"Draft","conditions":["publishAt is set and >= now()"]},{"to":"Published","from":"Draft","conditions":["Author publishes immediately"]},{"to":"Published","from":"Scheduled","conditions":["publishAt reached; system publishes"]},{"to":"Draft","from":"Scheduled","conditions":["Author cancels scheduled publish"]},{"to":"Expired","from":"Published","conditions":["expireAt reached"]},{"to":"Archived","from":"Published","conditions":["Admin manually archives"]},{"to":"Archived","from":"Expired","conditions":["Admin manually archives"]}],"initialState":"Draft"},"calculations":[{"name":"readTimeMinutes","formula":"MAX(1, CEIL(word_count(body) / 200))","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Inherited attachments and heroMedia must reference Media records whose visibility is compatible with the post's resolved audience — a restricted asset on a post visible to all locations is a configuration error","entity":"Media"},{"rule":"Author and any User in audience.includeUsers/excludeUsers must belong to the same Company","entity":"User"},{"rule":"All targetLocations must belong to the same Company as this post","entity":"Location"}]}},{"name":"Cost Adjustment","class":"Transactional","subsystem":"CONNECT","area":"Products & Pricing","desc":"Transactional document recording a cost change applied to items for a specific vendor. Writes entries to the Cost Ledger. Standard in retail merchandising systems for managing vendor cost lifecycle events. Distinct from option-level vendorCostModifier deltas on OptionValues which are relative and non-transactional.","status":"stub","properties":[{"n":"adjustmentId","r":true,"t":"string"},{"n":"costLevel","t":"entityDetail","re":"Cost Level"},{"n":"currency","r":true,"t":"enumeration","v":["USD","CAD","EUR","GBP","AUD","NZD","MXN","JPY","CNY","INR","BRL","CHF","SEK","NOK","DKK","SGD","HKD","KRW","ZAR","AED"],"info":"ISO 4217 currency code for this cost adjustment."},{"n":"item","r":true,"t":"entityRef","re":"Item"},{"n":"newCost","r":true,"t":"decimal"},{"n":"oldCost","r":true,"t":"decimal"},{"n":"reason","t":"string"},{"n":"vendor","r":true,"t":"entityRef","re":"Vendor"}],"ext":"TransactionalDocument","related":["Product","Item","Vendor","Cost Level","Cost Ledger"],"bv":{"rules":[{"rule":"Must be >= 0","when":"always","field":"NewCost","severity":"error"},{"rule":"Must differ from OldCost","when":"submit","field":"NewCost","severity":"error"},{"rule":"Must not be in the past unless backdating is enabled in Company settings","when":"create","field":"EffectiveDate","severity":"warning"}],"lifecycle":{"states":["Draft","Approved","Posted","Cancelled"],"transitions":[{"to":"Approved","from":"Draft","conditions":["At least one item line exists","Vendor is valid and active","NewCost differs from OldCost"]},{"to":"Posted","from":"Approved","conditions":["Cost Ledger entries written","Item cost updated at the specified Cost Level"]},{"to":"Cancelled","from":"Draft","conditions":[]},{"to":"Cancelled","from":"Approved","conditions":["Not yet posted"]}],"initialState":"Draft"},"calculations":[{"name":"CostDelta","formula":"NewCost - OldCost","trigger":"On cost value change"},{"name":"CostDeltaPct","formula":"((NewCost - OldCost) / OldCost) × 100","trigger":"On cost value change"}],"crossEntityConstraints":[{"rule":"Must reference a valid active Vendor","entity":"Vendor"},{"rule":"Must reference a valid active Item","entity":"Item"},{"rule":"Must reference a valid Cost Level if provided","entity":"Cost Level"},{"rule":"Posting writes an immutable Cost Ledger entry recording the old→new cost change","entity":"Cost Ledger"}]}},{"name":"Cost Ledger","class":"Ledger","subsystem":"CONNECT","area":"Products & Pricing","desc":"Immutable chronological audit log of vendor unit cost changes. Records entries for both Product/Vendor and Item/Vendor relationships at each cost level. Functions identically to the Price Ledger but for vendor purchase costs rather than retail prices.","status":"stub","properties":[{"c":true,"n":"baseCurrencyUnitCost","t":"decimal","info":"unitCost × exchangeRate — the agreed vendor cost expressed in the Company's base currency, which is the currency the retailer always reasons about cost in. Null when currencyCode equals the Company currency. Enables cross-vendor cost comparison and consolidated margin reporting where vendors invoice in different currencies. Indicative at the agreed rate, not the settled rate — see exchangeRate. Named baseCurrencyUnitCost rather than baseUnitCost deliberately: VendorItemValue.baseCost already means the vendor's base cost before cost-level overrides, a different concept."},{"n":"costLevel","t":"entityDetail","re":"Cost Level","info":"Cost level this entry applies to (parallels PriceLevel on Price Ledger)"},{"n":"currencyCode","r":true,"t":"string","info":"ISO 4217 alpha-3 code (e.g. 'USD') for unitCost. Flat code, never entityDetail → Currency and never an enumeration — per the ledger-currency-is-iso-string convention. Defaults from Vendor.defaultCurrency, which may differ from the Company currency for import vendors."},{"n":"exchangeRate","t":"decimal","info":"Rate from currencyCode (the vendor's transacting currency) to the Company's currency, captured at posting so the historical figure stays stable as rates move. Null when currencyCode equals the Company currency. Required here — unlike Price Ledger — because the vendor genuinely transacts in their own currency: currencyCode + unitCost is what the vendor charges, and exchangeRate is what determines the cost to the retailer, which is always expressed in the Company's base currency. INDICATIVE, NOT SETTLED: this is the rate at the time the cost was agreed, not the rate ultimately paid — the settled rate lands on the Bill at invoice time, and inventory valuation uses Stock Ledger.exchangeRate at receipt. Do not reconcile baseCurrencyUnitCost against AP."},{"n":"item","t":"entityRef","re":"Item","info":"Item-level cost entry (for Item/Vendor granularity); mutually exclusive with Product"},{"c":true,"n":"ledgerLine","r":true,"t":"integer"},{"n":"product","t":"entityRef","re":"Product","info":"Product-level cost entry (for Product/Vendor granularity); mutually exclusive with Item"},{"n":"unitCost","r":true,"t":"decimal","info":"The new vendor unit cost for this item"},{"n":"vendor","r":true,"t":"entityRef","re":"Vendor","info":"The vendor whose unit cost is being recorded"}],"ext":"LedgerEntry","notes":"Mirrors the Price Ledger pattern. Each entry records a vendor unit cost change at either the Product/Vendor or Item/Vendor level for a given cost level.","related":["Vendor","Product","Item","Cost Level"],"bv":{"rules":[{"rule":"Append-only — existing entries must not be modified or deleted","when":"always","field":"LedgerLine","severity":"error"},{"rule":"Must be non-negative","when":"create","field":"UnitCost","severity":"error"},{"rule":"Exactly one of Product or Item must be set per entry","when":"create","field":"Product|Item","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Product-level entries track vendor cost per Product × Vendor × CostLevel","entity":"Product"},{"rule":"Item-level entries track vendor cost per Item × Vendor × CostLevel","entity":"Item"},{"rule":"Must reference a valid Cost Level","entity":"Cost Level"}]}},{"name":"Cost Level","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Tier or level at which product costs are defined (e.g. by vendor, by region, by channel). Extends LookupEntity — no additional fields beyond the base.","status":"stub","properties":[{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company."}],"ext":"LookupEntity","related":["Market","Vendor","Item"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Country","class":"Dictionary","subsystem":"CONNECT","area":"Organization","desc":"ISO 3166 country reference. Used for country of origin on products, address countries, tax jurisdictions, and regulatory compliance. Pure LookupEntity — no additional fields beyond the base.","status":"stub","properties":[],"ext":"LookupEntity","related":["Product"]},{"name":"Coupon","class":"Operational","subsystem":"CONNECT","area":"Promotions","desc":"A unique or generic code that customers redeem to activate a Promotion or Discount.","status":"stub","properties":[{"n":"code","r":true,"t":"string","u":true},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. Also the scope within which a coupon code must be unique — two tenants may legitimately both issue SUMMER20."},{"n":"couponId","r":true,"t":"string"},{"n":"expiryDate","t":"datetime"},{"n":"promotion","r":true,"t":"entityRef","re":"Promotion"},{"c":true,"n":"usageCount","t":"integer"},{"n":"usageLimit","t":"integer"}],"shopify":"DiscountCode resource","related":["Promotion","Sales Order"],"bv":{"rules":[],"lifecycle":null,"calculations":[{"name":"usageCount","formula":"COUNT(Sales Order WHERE coupons CONTAINS this)","trigger":"query-time"}],"crossEntityConstraints":[]}},{"name":"Currency","class":"Dictionary","subsystem":"ALLPOINT","area":"Organization","desc":"ISO 4217 currency definition. Defines the currencies available for use across the platform — pricing, costing, transactions, and vendor payments. Each currency carries its code (e.g. USD, EUR, GBP), display symbol, and decimal precision. Referenced by Market.baseCurrency, Location.baseCurrency, Vendor.orderCurrency, and monetary fields throughout the system.","status":"draft","properties":[{"n":"symbol","r":true,"t":"string","info":"Display symbol for the currency (e.g. $, €, £, ¥)."},{"n":"decimalPrecision","r":true,"t":"integer","info":"Number of decimal places for monetary amounts in this currency. Typically 2 (USD, EUR) or 0 (JPY, KRW)."},{"n":"isoNumericCode","t":"string","info":"ISO 4217 three-digit numeric code (e.g. 840 for USD, 978 for EUR)."}],"ext":"LookupEntity","related":["Market","Location","Vendor"]},{"name":"Custom Unit","class":"Dictionary","subsystem":"CONFIG","area":"Configuration","desc":"A user-defined unit of measure or business-specific enumeration extending platform defaults for a given Company or Market.","status":"stub","properties":[{"n":"code","r":true,"t":"string","u":true,"info":"Human-readable key. Unique within scope."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. The entity's own definition is 'user-defined … extending platform defaults for a given Company', so the scope is the point of it."},{"n":"name","r":true,"t":"string","u":true},{"n":"unitId","r":true,"t":"string"}],"service":"APR Config","shopify":"weight_unit on products/variants","related":["Company","Market","Item"],"bv":{"rules":[{"rule":"Must be unique within the Company","when":"create","field":"Code","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Customer","class":"Operational","subsystem":"CONNECT","area":"Customers","desc":"A person or account that purchases goods. Carries credit profile and purchase history.","status":"draft","properties":[{"n":"anniversaryDate","t":"datetime","info":"Customer anniversary date for marketing."},{"n":"billToAddresses","r":true,"t":"array","info":"Billing addresses."},{"n":"birthday","t":"datetime","info":"Customer birthday for marketing and age verification."},{"n":"contacts","r":true,"t":"array","info":"Customer contacts."},{"n":"creditLimit","t":"decimal"},{"n":"customerNo","r":true,"t":"integer"},{"n":"emails","r":true,"t":"array","info":"Customer email addresses with communication preferences."},{"n":"firstName","t":"string"},{"n":"gender","t":"string","info":"Customer gender."},{"n":"isActive","r":true,"t":"boolean","info":"Whether the customer account is active."},{"n":"isCompany","r":true,"t":"boolean","info":"Whether this customer represents a company/organization."},{"n":"isEmployee","r":true,"t":"boolean","info":"Whether this customer is also an employee."},{"n":"isTaxExempt","r":true,"t":"boolean","info":"Whether the customer is exempt from tax."},{"n":"isWholesale","r":true,"t":"boolean","info":"Whether this is a wholesale customer."},{"n":"lastName","t":"string"},{"n":"loyaltyPrograms","r":true,"t":"array","re":"Loyalty Program","info":"Loyalty program enrollments with points balances."},{"n":"memberships","r":true,"t":"array","info":"Customer membership records with type and expiration."},{"n":"middleName","t":"string","info":"Customer middle name."},{"n":"organization","t":"string","info":"Company or organization name if isCompany is true."},{"n":"phones","r":true,"t":"array","info":"Customer phone numbers with communication preferences."},{"n":"postalAddresses","r":true,"t":"array","info":"Postal addresses."},{"n":"rfmGroup","t":"entityDetail","re":"RFM Group","info":"Customer's current RFM (Recency, Frequency, Monetary) segmentation group. Computed by analytics from purchase history and used to drive targeted marketing, retention, and service tiering."},{"n":"shipToAddresses","r":true,"t":"array","info":"Shipping addresses."},{"n":"title","t":"string","info":"Name title/prefix (e.g. Mr, Mrs, Dr)."}],"ext":"OperationalDocument","shopify":"Customer resource","related":["Sales Order","Customer Credit","Sale"],"bv":{"rules":[{"rule":"Must be a valid email format and unique within the Company if provided","when":"always","field":"Email","severity":"error"},{"rule":"Must be >= 0 if provided","when":"always","field":"CreditLimit","severity":"error"}],"lifecycle":{"states":["Active","Inactive","Archived"],"transitions":[{"to":"Inactive","from":"Active","conditions":["No open orders"]},{"to":"Active","from":"Inactive","conditions":[]},{"to":"Archived","from":"Inactive","conditions":["No outstanding balance","Meets data-retention policy"]}],"initialState":"Active"},"calculations":[{"name":"salesOrderCount","formula":"COUNT(Sales Order WHERE customer = this)","trigger":"query-time"},{"name":"saleCount","formula":"COUNT(Sale WHERE customer = this)","trigger":"query-time"},{"name":"creditCount","formula":"COUNT(Customer Credit WHERE customer = this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Cannot archive a Customer with open Sales Orders","entity":"Sales Order"},{"rule":"Archiving a Customer does not expire their Gift Cards","entity":"Gift Card"}]},"inlineSchemas":[{"name":"CustomerMembership","extends":"OperationalSubDocument","properties":[{"info":"Date the membership expires.","name":"expirationDate","type":"datetime"},{"info":"Type or level of the membership.","name":"membershipType","type":"string","required":true},{"info":"Date the membership started.","name":"startDate","type":"datetime"}]},{"name":"CustomerLoyaltyProgram","extends":"OperationalSubDocument","properties":[{"info":"Date the customer enrolled in this loyalty program.","name":"enrolledDate","type":"datetime"},{"info":"Reference to the Loyalty Program.","name":"loyaltyProgramId","type":"string","required":true},{"info":"Current points balance in this program.","name":"pointsBalance","type":"integer","required":true},{"info":"Current loyalty tier within the program.","name":"tierId","type":"string"}]}]},{"name":"Customer Credit","class":"Balance","subsystem":"CONNECT","area":"Payments & Stored Value","desc":"Monetary credit balance held on a customer account for future purchases or refunds.","status":"stub","properties":[{"n":"balance","r":true,"t":"decimal"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. As with Gift Card, this balance is redeemable value, so the boundary is a financial control and not only a data-visibility one."},{"n":"creditId","r":true,"t":"string"},{"n":"customer","r":true,"t":"entityRef","re":"Customer"},{"n":"expiryDate","t":"date"}],"shopify":"Store credit / Refund adjustment","related":["Customer","Sale"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Dashboard","class":"Core","subsystem":"CONNECT","area":"Analytics","desc":"A configured dashboard composed of one or more Report widgets. Provides a consolidated view of analytics across domains. Dashboards are scoped by franchise group and can be shared or personal.","status":"stub","properties":[{"n":"code","r":true,"t":"string","u":true,"info":"Human-readable key. Unique within scope."},{"n":"name","r":true,"t":"string"}],"ext":"BaseDocument","related":["Report"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Discount","class":"Dictionary","subsystem":"CONNECT","area":"Promotions","desc":"A price reduction rule (%, fixed amount, BOGO, etc.) referenced by Promotions and applied at Sale.","status":"stub","properties":[{"n":"code","r":true,"t":"string","u":true,"info":"Human-readable key. Unique within scope."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it."},{"n":"discountId","r":true,"t":"string"},{"n":"name","r":true,"t":"string","u":true}],"shopify":"Discount types in GraphQL","related":["Promotion","Price Rule"],"bv":{"rules":[{"rule":"Must be before EndDate if both are set","when":"always","field":"StartDate","severity":"error"}],"lifecycle":{"states":["Draft","Active","Expired","Archived"],"transitions":[{"to":"Active","from":"Draft","conditions":["Start date reached or manually activated"]},{"to":"Expired","from":"Active","conditions":["End date passed"]},{"to":"Archived","from":"Active","conditions":[]},{"to":"Archived","from":"Expired","conditions":[]}],"initialState":"Draft"},"calculations":[{"name":"promotionCount","formula":"COUNT(Promotion WHERE discounts CONTAINS this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Applied discount amount recorded on Sale line","entity":"Sale"}]}},{"name":"Document Custom Data Config","class":"Core","subsystem":"CONNECT","area":"Platform","desc":"Metadata definition for extensible custom fields attached to documents. Configures field type, label, allowed values, and the target document type (32+ supported). System-wide schema extension mechanism.","status":"stub","properties":[{"n":"availableValues","r":true,"t":"array","info":"Allowed values for lookup or multiselect field types."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. Tenant-scoped despite its Core class: custom-field definitions are one tenant's extensions to the document model, not part of the platform's own catalogue."},{"n":"fieldType","r":true,"t":"enumeration","v":["text","longtext","integer","decimal","date","flag","lookup","multiselect"]},{"n":"isDeleted","r":true,"t":"boolean"},{"n":"label","r":true,"t":"string","info":"User-facing display label."},{"n":"relatedDocumentType","r":true,"t":"enumeration","v":["PurchaseOrder","SalesOrder","TransferOrder","StockAdjustment","StockTake","GoodsReceipt","Sale","Bill","VendorInvoice","VendorCredit"],"info":"The document type this custom field attaches to (e.g. PurchaseOrder, SalesReceipt, Vendor, Customer)."},{"n":"sequence","t":"integer","info":"Display ordering hint."},{"n":"sourceFieldName","r":true,"t":"string","info":"Underlying field key in the custom data payload."}],"notes":"Supports 32+ document types including: PurchaseOrder, SalesOrder, SalesReceipt, Vendor, Customer, Employee, Location, StockTransfer, and more.","related":[]},{"name":"Document Type","class":"Dictionary","subsystem":"CONFIG","area":"Configuration","desc":"Defines a category of structured document managed within the platform (PO, ASN, Invoice).","status":"stub","properties":[{"n":"code","r":true,"t":"string","u":true,"info":"Human-readable key. Unique within scope."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. Tenant-scoped rather than platform-global because Document Type is the policy anchor for its category — approval thresholds, numbering and expiration are the tenant's own rules, not facts about the software."},{"n":"configuration","r":true,"t":"valueType","info":"Reference to this Document Type's policy configuration in the CONFIG subsystem. Uses the ConfigurationRef value type (config: entityDetail → Config, templateCode: string). Acts as the policy anchor for every document of this type — e.g., the Purchase Order Document Type's Config holds rules like autoExpireDaysAfterCancellation, requireApprovalOverAmount, numberingPrefix. Concrete documents (Purchase Order, Bill, ASN, Vendor Invoice) resolve their effective policy at runtime by reading documentType.configuration.config rather than carrying per-document overrides. The templateCode field names the Config Template that defines which policy keys are valid for this Document Type."},{"n":"description","t":"string"},{"n":"documentTypeId","r":true,"t":"string"},{"n":"name","r":true,"t":"string","u":true},{"n":"processingRules","t":"schema"},{"n":"schema","t":"schema"}],"service":"APR Config","related":["Config Template"],"bv":{"rules":[{"rule":"Must be unique","when":"create","field":"Code","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Email Log","class":"Ledger","subsystem":"CONNECT","area":"Messaging","desc":"Immutable delivery audit trail. One record per email sent via AWS SES. Tracks the full delivery lifecycle from queue through delivery, bounce, or complaint. Records are append-only — status updates are written as new action entries, not field overwrites. Links back to the triggering entity and template for traceability. Inherited sourceEntityType/sourceEntityId identify the entity that triggered the email (e.g. 'Sales Order' / orderId).","status":"draft","properties":[{"n":"bounceReason","t":"string","info":"SES bounce diagnostic message. Only populated when status is bounced."},{"n":"bounceType","t":"enumeration","v":["hard","soft","undetermined"],"info":"Bounce classification. Hard bounces trigger recipient suppression. Only set when status is bounced."},{"n":"deliveredAt","t":"datetime","info":"SES delivery confirmation timestamp. Null until delivery is confirmed."},{"n":"messageId","r":true,"t":"string","info":"SES Message-ID returned on send. Used for delivery status tracking via SES notifications."},{"n":"recipientEmail","r":true,"t":"string","info":"Destination email address."},{"n":"recipientUser","t":"entityRef","re":"User","info":"User reference if the recipient is a platform user. Null for external recipients (e.g. vendor contacts)."},{"n":"sentAt","t":"datetime","info":"When the email was handed to SES for delivery. Null while status is queued."},{"n":"status","r":true,"t":"schema","info":"Delivery status of the email. Tracks current state and who/when it was last changed."},{"n":"subject","r":true,"t":"string","info":"Rendered subject line after variable interpolation."},{"n":"template","t":"entityRef","re":"Email Template","info":"The Email Template used to render this email. Null for ad-hoc emails not based on a template."}],"ext":"LedgerEntry","related":["Email Template","User"],"bv":{"rules":[{"rule":"Must be unique — one log entry per SES send","when":"create","field":"MessageId","severity":"error"},{"rule":"Transitions are append-only — status can only move forward, never backward","when":"always","field":"Status","severity":"error"}],"lifecycle":{"states":["Queued","Sent","Delivered","Bounced","Complained","Failed"],"transitions":[{"to":"Sent","from":"Queued","conditions":["SES accepts the send request"]},{"to":"Failed","from":"Queued","conditions":["SES rejects the send request (invalid address, suppressed recipient)"]},{"to":"Delivered","from":"Sent","conditions":["SES delivery notification received"]},{"to":"Bounced","from":"Sent","conditions":["SES bounce notification received"]},{"to":"Complained","from":"Sent","conditions":["SES complaint notification received (recipient marked as spam)"]}],"initialState":"Queued"},"calculations":[],"crossEntityConstraints":[{"rule":"If template is set, the template must exist and be Active at send time","entity":"Email Template"},{"rule":"If recipientUser is set, must reference a valid User within the same Company","entity":"User"}]},"inlineSchemas":[{"name":"EmailLogStatus","schema":"schemas/email-log/EmailLogStatus.ts","properties":[{"info":"Current delivery status. Updated via SES notification webhooks.","name":"status","type":"enumeration","values":["queued","sent","delivered","bounced","complained","failed"],"required":true},{"info":"User or system actor who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"}]}]},{"name":"Email Template","class":"Operational","subsystem":"CONNECT","area":"Messaging","desc":"A reusable, tenant-scoped email layout with variable placeholders for merge-field interpolation. Templates define the subject, HTML body, plain-text fallback, and sender identity. Each template can be linked to an entity state transition to trigger automated transactional emails (e.g. Sales Order → Completed fires an order-confirmation email via AWS SES).","status":"draft","properties":[{"n":"bodyHtml","r":true,"t":"string","info":"HTML email body with {{variable}} placeholders for merge-field interpolation."},{"n":"bodyText","t":"string","info":"Plain-text fallback body. Used when the recipient's client does not render HTML."},{"n":"fromEmail","r":true,"t":"string","info":"Sender email address. Must be a verified identity in the Company's SES configuration."},{"n":"fromName","r":true,"t":"string","info":"Sender display name shown in the From header."},{"n":"isActive","r":true,"t":"boolean","info":"Whether this template is currently in use. Inactive templates are skipped by the delivery pipeline."},{"n":"subject","r":true,"t":"string","info":"Email subject line. Supports {{variable}} interpolation."},{"n":"templateCode","r":true,"t":"string","info":"Machine-readable template key (e.g. order-confirmation, po-submitted). Unique within the Company."},{"n":"triggerEntity","t":"string","info":"Entity name whose state transition fires this template (e.g. 'Sales Order'). Null for manually-triggered emails."},{"n":"triggerState","t":"string","info":"The lifecycle state transition that triggers delivery (e.g. 'Completed'). Used with triggerEntity to wire automatic sends."},{"n":"variables","r":true,"t":"array","info":"Documents available merge fields for this template."}],"ext":"OperationalDocument","related":["Email Log"],"bv":{"rules":[{"rule":"Must be unique within the Company and follow kebab-case naming","when":"always","field":"TemplateCode","severity":"error"},{"rule":"Must be a verified sender identity in the Company's SES configuration","when":"always","field":"FromEmail","severity":"error"},{"rule":"All {{variable}} placeholders must have a matching entry in the variables array","when":"always","field":"BodyHtml","severity":"warning"}],"lifecycle":{"states":["Draft","Active","Inactive"],"transitions":[{"to":"Active","from":"Draft","conditions":["Subject and bodyHtml are non-empty","fromEmail is a verified SES identity"]},{"to":"Inactive","from":"Active","conditions":["Manual deactivation or replacement by newer template"]},{"to":"Active","from":"Inactive","conditions":["Re-activation by admin"]},{"to":"Draft","from":"Active","conditions":["Template pulled back for revision"]}],"initialState":"Draft"},"calculations":[{"name":"sendCount","formula":"COUNT(Email Log WHERE template = this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Each sent email creates an immutable Email Log entry referencing this template","entity":"Email Log"}]}},{"name":"Employee","class":"Operational","subsystem":"CONNECT","area":"Organization","desc":"A staff member of the retailer, with POS login credentials, a role, location assignments, operational functions, and effective-dated compensation. Employee credentials are POS-specific and independent of ACCESS — the optional user reference links an Employee to an ACCESS User identity where the staff member also has access to All Point applications, but most store staff have no User. All Point is not the system of record for payroll: compensation is held for labor costing and scheduling only, keyed to the external payroll system via externalPayrollId, and models no deductions, tax setup or garnishments.","status":"draft","properties":[{"n":"address","t":"valueType","vt":"Address","info":"Employee's personal address."},{"n":"changePasswordOnNextLogin","r":true,"t":"boolean","info":"Flag requiring password change on next login."},{"n":"compensationRecords","r":true,"t":"array","info":"Array of EmployeeCompensation sub-documents forming the employee's effective-dated pay history. Replaces the former flat hourlyRate property. Exactly one record should have a null effectiveTo (the currently effective rate), and effective ranges must not overlap. Restricted data — expose via field-level permissions rather than on general Employee reads."},{"n":"email","r":true,"t":"string","u":true},{"n":"employeeNo","r":false,"t":"string","u":true,"info":"Human-readable employee number. Optional — an Employee may be created without one — but SPARSE UNIQUE when populated: no two employees may share an employeeNo within the same company / franchise group grain. Null is not a value and does not collide, so many employees may have none. Enforce with a partial unique index (WHERE employeeNo IS NOT NULL), not a plain unique constraint, or a single second null will collide in engines that treat nulls as equal. Uniqueness is scoped, not global — the same number may legitimately recur in a different franchise group. See businessValidation.rules for the grain and the multi-group resolution."},{"n":"employmentType","r":true,"t":"enumeration","v":["FullTime","PartTime","Temporary","Seasonal","Contractor"],"info":"Employment engagement type. A headcount and scheduling concept — orthogonal to payBasis (how pay is expressed) and overtimeStatus (legal overtime classification). Do not infer pay basis or overtime eligibility from this value."},{"n":"expirationDate","t":"datetime","info":"Account expiration date. Null for non-expiring accounts."},{"n":"externalPayrollId","t":"string","info":"Identifier for this employee in the external payroll system of record (e.g. ADP, Gusto, QuickBooks Payroll). All Point is not the system of record for payroll — it holds pay rates for labor costing and scheduling only, and models no deductions, tax setup or garnishments."},{"n":"firstName","r":true,"t":"string"},{"n":"functions","r":true,"t":"array","v":["Buyer","Cashier","SalesPerson","StockHandler","Manager"],"info":"Operational functions this employee can perform. Used to filter employee references on transactional documents (e.g. PO → Buyer, Sale → Cashier / SalesPerson). Scoped to the retailer's own staff operations and independent of ACCESS — an Employee with no User record still carries functions, and ACCESS authorization is governed by User roles and applications instead."},{"n":"hireDate","r":true,"t":"date","info":"Date employment began. Distinct from expirationDate, which is POS account expiry rather than an employment boundary. Used for tenure, seniority and benefits-eligibility reporting."},{"n":"lastName","r":true,"t":"string"},{"n":"locations","r":true,"t":"array","re":"Location","info":"Array of EmployeeLocation sub-documents. Each entry references a Location with an isPrimary flag."},{"n":"middleName","t":"string"},{"n":"nickName","t":"string","info":"Preferred display name."},{"n":"notes","t":"string","info":"Free-form internal notes about the employee."},{"n":"overtimeStatus","r":true,"t":"enumeration","v":["Exempt","NonExempt"],"info":"Overtime eligibility classification (FLSA in the US). NonExempt employees accrue overtime; Exempt employees do not. MUST NOT be derived from payBasis — salaried non-exempt is common for retail store management, and inferring exemption from a Salary pay basis produces wage-and-hour underpayment. Set explicitly per employee."},{"n":"passwordChangeDate","t":"datetime","info":"When the password was last changed."},{"n":"phone","t":"string"},{"n":"role","t":"entityDetail","re":"Employee Role"},{"n":"standardHoursPerWeek","t":"decimal","info":"Scheduled hours per week under normal conditions. Drives FTE calculation, labor budgeting and benefits-eligibility thresholds. For Salary pay basis it also provides the divisor needed to derive an implied hourly rate for labor costing."},{"n":"status","r":true,"t":"schema","info":"EmployeeStatus inline schema capturing current document status."},{"n":"terminationDate","t":"date","info":"Date employment ended. Null for active employees. Termination does not delete compensation history — dated compensation records are retained for historical labor-cost accuracy."},{"n":"user","r":false,"t":"entityDetail","u":true,"re":"User","info":"Optional reference to an ACCESS User identity. Populated only for employees who also have access to All Point applications and systems; most store staff have no User. Employee credentials (username, password, expiration) are POS login credentials and are independent of this link — an Employee can log into the POS with no User record at all. One-to-one when present: a User backs at most one Employee."},{"n":"username","r":true,"t":"string","info":"Login username for the employee."}],"ext":"OperationalDocument","shopify":"Staff / User resource","related":["Employee Role","Location","User","Purchase Order","Sale","Sales Order","Commission Plan"],"bv":{"rules":[{"rule":"employeeNo is optional but SPARSE UNIQUE within the company / franchise group grain when populated: for any two Employees sharing the same company AND overlapping in at least one franchise group, employeeNo must differ. Null employeeNo never collides — enforce via a partial unique index (WHERE employeeNo IS NOT NULL) rather than a plain unique constraint, since engines that treat nulls as equal will reject the second unpopulated record. NOTE ON GRAIN: Employee inherits franchiseGroups as an ARRAY from OperationalDocument, so the grain is set-valued and cannot be expressed as a single composite unique index on (company, franchiseGroup, employeeNo). The overlap rule above is the intended semantics and needs either (a) a resolved primary franchise group to key on, or (b) application-level enforcement over the array. Confirm which before implementation.","when":"employeeNo is populated","field":"employeeNo","severity":"error"}],"lifecycle":{"states":["Draft","Active","Archived"],"transitions":[{"to":"Active","from":"Draft","conditions":["Location assigned"]},{"to":"Archived","from":"Active","conditions":[]}],"initialState":"Draft"},"calculations":[],"crossEntityConstraints":[{"rule":"Must be assigned to a valid active Location","entity":"Location"},{"rule":"Cannot archive Employee with Sales in the current fiscal period","entity":"Sale"}]},"inlineSchemas":[{"name":"EmployeeStatus","extends":"OperationalSubDocument","properties":[{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"},{"info":"Current document lifecycle status of the employee.","name":"documentStatus","type":"enumeration","values":["Draft","Active","Archived"],"required":true}]},{"name":"EmployeeLocation","extends":"OperationalSubDocument","properties":[{"info":"Whether this is the employee's primary location.","name":"isPrimary","type":"boolean","required":true},{"info":"Reference to the assigned Location.","name":"location","type":"entityDetail","relatedEntity":"Location"}]},{"name":"EmployeeCompensation","extends":"OperationalSubDocument","properties":[{"info":"How pay is expressed for this compensation period. Orthogonal to the Employee's employmentType and overtimeStatus — a Salary pay basis does not imply Exempt overtime status.","name":"payBasis","type":"enumeration","values":["Hourly","Salary","Commission","SalaryPlusCommission"],"required":true},{"info":"Pay amount with explicit currency, stored as entered. Interpret against rateBasis — the amount alone is ambiguous between an hourly, annual and per-period figure. Null-rate records are invalid; Commission-only arrangements record a zero base rate plus a commission plan reference.","name":"rate","type":"valueType","required":true,"relatedEntity":"Money"},{"info":"The period the rate amount is expressed in. Kept separate from payBasis because a Salary basis may be captured as an annual or a per-period figure. All derived figures (implied hourly rate, annualized cost) are calculated from rate + rateBasis + the Employee's standardHoursPerWeek rather than stored.","name":"rateBasis","type":"enumeration","values":["PerHour","PerYear","PerPayPeriod"],"required":true},{"info":"How often pay is disbursed. Informational on the All Point side — the external payroll system owns the actual pay calendar and pay-run execution.","name":"payFrequency","type":"enumeration","values":["Weekly","BiWeekly","SemiMonthly","Monthly"]},{"info":"First date this compensation record applies. Point-in-time rate resolution for timesheets and labor costing selects the record whose effective range contains the work date, so historical transaction costs are never rewritten by a later raise.","name":"effectiveFrom","type":"date","required":true},{"info":"Last date this compensation record applies. Null means this is the currently effective record. Effective ranges must not overlap for a given employee.","name":"effectiveTo","type":"date"},{"info":"Commission plan in force for this period. Required when payBasis is Commission or SalaryPlusCommission. Referenced rather than inlined because plans are shared across employees and carry tiered rate structures of their own. NOTE: the Commission Plan entity is not yet defined in the registry.","name":"commissionPlan","type":"entityDetail","relatedEntity":"Commission Plan"},{"info":"User ID who created this compensation record.","name":"changedBy","type":"string"},{"info":"Why this compensation record was created. Supports compensation-change auditing and distinguishes a genuine rate change from a correction to a mis-keyed prior record.","name":"changeReason","type":"enumeration","values":["Hire","MeritIncrease","Promotion","MinimumWageAdjustment","ScheduleChange","Correction"]}]}]},{"name":"Employee Role","class":"Dictionary","subsystem":"CONNECT","area":"Organization","desc":"Named role or job function assigned to employees defining their responsibilities and access.","status":"stub","properties":[{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. Same reasoning as Role in the ACCESS family: two Companies may both name a role 'Store Manager' and mean different things by it."},{"n":"description","t":"string"},{"n":"name","r":true,"t":"string","u":true},{"n":"permissions","r":true,"t":"array"},{"n":"roleId","r":true,"t":"string"}],"ext":"LookupEntity","related":["Employee"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Entity Action Log","class":"Ledger","subsystem":"CONNECT","area":"Platform","desc":"Immutable, append-only audit trail of all actions performed on any entity in the system. Each record captures a single action — either a field-level change (FieldChange) or a domain operation (Operation) — along with the actor, timestamp, and optional before/after values. Entities carry a denormalized recentActions array (capped at the last 5 entries) for quick display; this ledger is the authoritative system of record for the full history. Inherited entryDate maps to the action timestamp; sourceEntityType/sourceEntityId identify the target entity. Inherited createdBy maps to the acting user.","status":"draft","properties":[{"n":"actionType","r":true,"t":"enumeration","v":["FieldChange","Operation"],"info":"Discriminator: FieldChange for property-level mutations, Operation for domain events (e.g. status transition, approval, publish)."},{"n":"changes","r":true,"t":"array","info":"Structured list of field-level changes that occurred as part of this action. For FieldChange actions, contains one or more entries detailing each property that was modified. For Operation actions, the array may be empty when the operation has no field-level side effects (e.g. 'print', 'export')."},{"n":"code","r":true,"t":"string","info":"Stable, machine-readable action identifier (e.g. 'status.transition', 'price.update', 'approve', 'line.add'). Provides an enumerable key for building reports, dashboards, and automation triggers without parsing the description or deriving intent from actionType alone. While actionType captures the category, code captures the semantic intent of the action."},{"n":"description","r":true,"t":"string","info":"Human-readable summary of what happened and why (e.g. 'Status changed from Draft to Active', 'Approved by manager', 'Quantity updated on 3 lines'). Serves as the display text in activity feeds and audit UIs."},{"n":"entityNo","t":"string","info":"Human-readable identifier from the source entity (e.g. productNo, salesOrderNo) at the time of the action. Denormalized for display without a join."},{"n":"metadata","t":"object","info":"Freeform context about the action. May include source IP, originating system, batch ID, or other operational context."},{"n":"subDocumentId","t":"uuid","info":"UUID of the sub-document/line item the action was performed on (e.g. a Purchase Order Line, Shipment Carton). Null when the action is at the header/entity level."},{"n":"subDocumentType","t":"string","info":"Sub-document type name (e.g. 'PurchaseOrderLine', 'ShipmentCarton'). Null when the action is at the header/entity level. Used alongside subDocumentId for indexed lookups."}],"ext":"LedgerEntry","related":[],"bv":{"rules":[{"rule":"When actionType is FieldChange, the changes array must contain at least one entry","when":"create","field":"actionType","severity":"error"},{"rule":"Must reference an existing entity of the specified entityType","when":"create","field":"entityId","severity":"error"},{"rule":"Records are immutable once written — no updates or deletes permitted","when":"always","field":"*","severity":"error"}],"calculations":[],"crossEntityConstraints":[]},"inlineSchemas":[{"name":"ActionChange","schema":"schemas/common/ActionChange.ts","properties":[{"info":"The property name that was modified (e.g. 'status', 'quantity', 'unitPrice').","name":"fieldName","type":"string","required":true},{"info":"Serialized new value after the change. Null when a property was removed/cleared.","name":"newValue","type":"string"},{"info":"Serialized old value before the change. Null when a property was set for the first time.","name":"previousValue","type":"string"}]}]},{"name":"Environment","class":"Dictionary","subsystem":"ACCESS","area":"Infrastructure","desc":"A named deployment context that scopes configuration, feature flags, and access control. Environments are dictionaries — they define the set of valid deployment targets rather than tracking runtime state. Typical values: dev (Development), qa (Quality Assurance), uat (User Acceptance Testing), prod (Production). A Client references an Environment to declare what context it operates in.","status":"draft","properties":[{"n":"code","r":true,"t":"string","info":"Human-readable key. Unique within scope."},{"n":"description","t":"string"},{"n":"name","r":true,"t":"string","u":true}],"service":"APR Access","ext":"LookupEntity","notes":"Canonical subsystem is PLATFORM (Infrastructure). Currently registered under ACCESS pending subsystem reassignment — needs delete-and-recreate to move to PLATFORM.","related":["Client"],"bv":{"rules":[{"rule":"Must be unique within the Company","when":"create","field":"Code","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Financial Summary","class":"Operational","subsystem":"CONNECT","area":"Finance","desc":"A period close for one Location: the financial activity of that Location over a bounded period, rolled up into the categories an accounting or ERP system posts as journal entries. One row per Location per period. Its purpose is HANDOFF, not analysis — it exists so that a day, week or month of retail activity becomes a small set of reconciled totals that can be pushed to an external general ledger and, once accepted, never restated. Everything the summary contains is derivable from the underlying documents (Sale, Stock Ledger, Cost Ledger, Purchase Order, Stock Transfer, Stock Adjustment, Gift Card); the value of materializing it is that the figures are frozen at close, carry a posting state, and can be reconciled against what the ERP actually received. THE CLOSE IS THE POINT. While status is Open the totals are recomputable and mean nothing durable. Closing freezes them. Posting records that an external system accepted them and stores the reference it returned. After that the document is immutable — a mistake found later is corrected by a reversing summary, never by editing a posted one, because the counterpart entry already exists in someone else's ledger. Component totals are transcribed from the implemented FinancialSummary schema; the close lifecycle, tenancy, currency and posting state are added here, since the implementation models the arithmetic but not the process around it.","status":"draft","properties":[{"n":"adjustmentSummaries","r":true,"t":"array","info":"AdjustmentSummary entries, one per stock adjustment reason code. Each carries the reason, its positive and negative quantity-value movements kept separate (netting them would hide shrink inside a favourable count), and the cost adjustment amount. Empty array default. Plural per array-name-plural; the implementation names this adjustmentSummary."},{"n":"closedAt","t":"datetime","info":"When the period was closed and its totals frozen. UTC. Null while status is Open. Set once and never changed — reopening a period does not clear it, it records a reopen in the audit trail instead."},{"n":"closedBy","t":"string","info":"The User who closed the period. Null while Open. A close is an accountable act, not a background job, even when the totals are computed automatically."},{"n":"cogsSummary","t":"schema","info":"CogsSummary. Cost of goods sold for the period, split taxable and non-taxable, with the cost adjustments applied to each. Sourced from the Stock Ledger's outbound entries at their consumed layer cost, so it reconciles to inventory valuation rather than to retail price."},{"n":"creditMemoSummary","t":"schema","info":"CreditMemoSummary. Credit memo amounts issued and returned, split taxable and non-taxable."},{"n":"currencyCode","r":true,"t":"string","info":"ISO 4217 alpha-3 code for every monetary figure in this summary. Declared explicitly rather than inherited from the Location's Market, because a posted summary must be self-contained: the ERP receiving it cannot resolve a Market, and a Location's Market assignment can change after the period is closed. Not modelled as entityDetail to Currency for the same reason the ledger entities avoid it — an immutable posted document must not embed a mutable dictionary snapshot."},{"n":"depositSummary","t":"schema","info":"DepositSummary. Customer deposits taken and returned during the period."},{"n":"discountSummaries","r":true,"t":"array","info":"DiscountSummary entries, one per discount code, split taxable and non-taxable. Empty array default."},{"n":"drawerMemoSummary","t":"schema","info":"DrawerMemoSummary. Cash movements in and out of the drawer that are not sales — paid-in, paid-out and the resulting balance. The till-level counterpart to tenderSummaries, and the figure a store manager reconciles against physical cash."},{"n":"externalReference","t":"string","info":"The identifier the accounting or ERP system returned when it accepted this summary — a journal entry number, batch id or import reference. Required once status is Posted, and the evidence that posting actually succeeded rather than merely being attempted. Without it a Posted summary cannot be traced to its counterpart entry."},{"n":"feeSummaries","r":true,"t":"array","info":"FeeSummary entries, one per fee code, split taxable and non-taxable. Empty array default."},{"n":"financialSummaryNo","r":true,"t":"string","u":true,"info":"Human-readable identifier, unique within Company. Per entity-no-property convention."},{"n":"fiscalPeriod","t":"string","info":"The accounting period this summary posts into, as the ERP labels it (e.g. '2026-08', 'FY26-P02'). Deliberately a plain string rather than a derived value: a fiscal calendar rarely aligns to calendar months, is defined in the accounting system rather than here, and a period close posted to the wrong period is worse than one posted late."},{"n":"inventorySummary","t":"schema","info":"InventorySummary. Inventory value movement over the period, split taxable and non-taxable with their return counterparts."},{"n":"location","r":true,"t":"entityDetail","re":"Location","info":"The Location this summary covers. One summary per Location per period — a store closes its own day, and consolidating across locations before posting would destroy the dimension most general ledgers post against."},{"n":"periodEnd","r":true,"t":"datetime","info":"Exclusive end of the period, UTC. Exclusive so that consecutive periods abut exactly without a boundary transaction landing in both or neither — the classic source of a close that is off by one sale."},{"n":"periodStart","r":true,"t":"datetime","info":"Inclusive start of the period, UTC."},{"n":"periodType","r":true,"t":"enumeration","v":["Day","Week","Month","Quarter","Year"],"info":"The close cadence this summary represents. Summaries of different periodTypes legitimately overlap — a Month contains its Days — so the non-overlap rule is scoped to one periodType at a time. Whether a Month is a separate close or a rollup of its Days is a posting-policy decision, not a schema one."},{"n":"postedAt","t":"datetime","info":"When the external system accepted this summary. UTC. Null until status is Posted."},{"n":"postingErrors","r":true,"t":"array<string>","info":"Messages returned by the accounting system on a failed posting attempt. Empty array default. Retained after a later successful post rather than cleared, because a summary that took three attempts is worth being able to see."},{"n":"purchasedGiftCardSummaries","r":true,"t":"array","info":"PurchasedGiftCardSummary entries, one per gift card type, split taxable and non-taxable with their return counterparts. Kept separate from saleSummary because a gift card sale is a liability, not revenue — posting it as revenue overstates the period and double-counts when the card is later redeemed. Empty array default."},{"n":"purchaseSummaries","r":true,"t":"array","info":"PurchaseSummary entries, one per vendor, carrying purchase cost, return cost and cost adjustments split by whether they have been invoiced. The invoiced/non-invoiced split is what lets accrued but unbilled goods be posted correctly. Empty array default."},{"n":"reversalOf","t":"entityRef","re":"Financial Summary","info":"The posted summary this one reverses. A posted summary is immutable, so a correction is a NEW summary carrying the compensating amounts and pointing back here — the same discipline the ledger entities use for corrections. Self-referential; the target must have status = 'Posted', since reversing something never sent is meaningless. Null on an ordinary summary."},{"n":"roundingError","r":true,"t":"decimal","info":"The residual left when the component totals are summed and compared against the period's source documents. Materialized rather than discarded because it is the close's own check on itself: a small value is expected from per-line rounding, and a growing one is the first sign that a category is being missed. A tolerance should gate the close rather than the posting."},{"n":"saleSummary","t":"schema","info":"SaleSummary. Gross sales and returns for the period, split taxable and non-taxable. The primary revenue figure, and the one most ERP mappings key on."},{"n":"status","r":true,"t":"enumeration","v":["Open","Closed","Posted","PostingFailed","Reversed"],"info":"Lifecycle state. Open = accumulating, totals recomputable and not durable. Closed = totals frozen, awaiting posting. Posted = accepted by the external system, immutable thereafter. PostingFailed = rejected, see postingErrors, retryable. Reversed = superseded by a reversing summary."},{"n":"taxSummaries","r":true,"t":"array","info":"TaxSummary entries, one per tax code, carrying tax on sales and fees. Empty array default. Note this is the merchant's own tax liability — tax a marketplace facilitator already remitted is a separate concern with no home yet, and posting the two together over-reports liability."},{"n":"tenderSummaries","r":true,"t":"array","info":"TenderSummary entries, one per tender type and subtype, carrying amounts taken and returned. The counterpart to saleSummary on the cash side: sales say what was earned, tenders say what was collected, and the difference between them is what a close is for. Empty array default."},{"n":"transferSummaries","r":true,"t":"array","info":"TransferSummary entries covering stock transferred out of this Location, with cost adjustments and a reason. Only the outbound leg is summarized here — the receiving Location records the inbound leg in its own summary, which is what keeps an inter-location transfer from being counted twice across the Company. Empty array default."}],"ext":"OperationalDocument","notes":"PROPOSED — not yet reviewed. Created 31 Aug 2026 in response to the finding that FinancialSummary is implemented in the types package but had no registry presence at all.\n\nWHAT WAS TRANSCRIBED vs WHAT WAS ADDED. The fourteen component summaries and roundingError are transcribed from src/validation/schemas/financialSummary/, so the arithmetic of the close reflects what is actually built. Everything around them is new here: the Open/Closed/Posted/PostingFailed/Reversed lifecycle, closedAt/closedBy, postedAt, externalReference, postingErrors, reversalOf, periodType, fiscalPeriod, currencyCode, financialSummaryNo, and the OperationalDocument base bringing company, franchiseGroups, audit and idmpKey. The implementation models the totals but not the process around them — it is a bare z.object with a location string and start/end dates, no identity, no tenancy and no state.\n\nTHE POSTING PATH IS WHY THE LIFECYCLE MATTERS. Once an external general ledger has accepted a batch, a counterpart entry exists in a system this platform does not control. Editing the summary afterwards silently desynchronizes two ledgers with no error anywhere, which is why Posted is immutable and corrections go through reversalOf. The same reasoning drives the idmpKey requirement: a network timeout after the ERP accepted the batch is indistinguishable from a rejection, so an un-keyed retry double-counts a period in someone else's books.\n\nDELIBERATE MODELLING CHOICES worth challenging in review:\n- Class is Operational, not Transactional, despite the transaction-like vocabulary. TransactionalDocument's contract is that it writes to at least one internal Ledger on posting; this document READS from the ledgers and posts outward to a foreign system. Operational plus an explicit lifecycle says that more honestly.\n- Grain is Location x periodType x period. Consolidating across locations before posting would destroy the dimension most general ledgers post against.\n- periodEnd is exclusive, so consecutive periods abut without a boundary transaction landing in both or neither.\n- currencyCode is a flat ISO string rather than entityDetail to Currency, following the ledger-currency precedent: a posted, immutable document must be self-contained and must not embed a mutable dictionary snapshot. The ERP receiving it cannot resolve a Market.\n- roundingError gates the CLOSE, not the posting. Once posted, a discrepancy has already left the building.\n- Arrays are pluralized per array-name-plural; the implementation uses singular names for array properties (adjustmentSummary, taxSummary).\n\nOPEN, and worth deciding before build:\n(1) Is a Month close an independent computation or a rollup of its Days? The schema permits either; posting policy does not.\n(2) Marketplace-facilitator tax has no home here, and taxSummaries as defined is the merchant's own liability. Posting facilitator-remitted tax through it would over-report. See the existing registry question on that gap.\n(3) Nothing models the ERP connection itself — which system, which account mapping, which journal. That likely belongs on Connection rather than here, but the link from a posted summary to the Connection that posted it does not exist.\n(4) No component covers labor or payroll, so this is a merchandising close rather than a full P&L.\n(5) CreditMemoSummary and DepositSummary are attributed to Customer Credit by inference; the implementation gives no indication of their source.","related":["Location","Sale","Stock Ledger","Cost Ledger","Purchase Order","Stock Transfer","Stock Adjustment","Gift Card","Vendor","Customer Credit","Tax Class"],"bv":{"rules":[{"rule":"periodEnd must be greater than periodStart. periodStart is inclusive and periodEnd exclusive, so consecutive periods abut exactly and no transaction lands in both or neither","when":"always","field":"PeriodEnd","severity":"error"},{"rule":"Two summaries for the same Location and periodType must not overlap. Summaries of DIFFERENT periodTypes may overlap freely — a Month legitimately contains its Days","when":"always","field":"PeriodStart","severity":"error"},{"rule":"A period cannot be closed while any source document within it is still in a non-final state — an open Sales Order, an unposted Goods Receipt, an in-flight Stock Transfer. Closing over in-flight activity produces totals that will not reconcile when those documents settle","when":"close","field":"Status","severity":"error"},{"rule":"roundingError must fall within the configured tolerance before the period can be CLOSED. Gating the close rather than the posting is deliberate: once posted, a discrepancy has already left the building","when":"close","field":"RoundingError","severity":"error"},{"rule":"closedAt and closedBy are required when status leaves Open, and are never cleared. Reopening records a new audit action rather than erasing the original close","when":"close","field":"ClosedAt","severity":"error"},{"rule":"IMMUTABLE ONCE POSTED. No property may be changed after status = 'Posted' — a counterpart journal entry now exists in a system this platform does not control, so an edit here silently desynchronizes two ledgers. Corrections are made by a reversing summary","when":"update","field":"Status","severity":"error"},{"rule":"externalReference is required when status = 'Posted'. Without the identifier the accounting system returned, a Posted summary cannot be traced to its counterpart entry and 'posted' is an unverifiable claim","when":"always","field":"ExternalReference","severity":"error"},{"rule":"postedAt is required when status = 'Posted' and must be >= closedAt","when":"always","field":"PostedAt","severity":"error"},{"rule":"Reopening a Closed summary is permitted before posting but must be permission-gated and audited. Reopening a Posted one is never permitted","when":"update","field":"Status","severity":"error"},{"rule":"reversalOf is set only on a summary whose purpose is to reverse another, and the target must have status = 'Posted'. Reversing an unposted summary is meaningless — nothing was ever sent","when":"always","field":"ReversalOf","severity":"error"},{"rule":"A summary may be reversed at most once. A second reversal of the same target indicates the first was itself wrong and should be corrected forward instead","when":"always","field":"ReversalOf","severity":"error"},{"rule":"The inherited idmpKey is required and unique within Company, composed from location + periodType + periodStart. Posting to an external system is retry-prone by nature — a network timeout after the ERP has accepted the batch is indistinguishable from a rejection — and a duplicate post double-counts a period in someone else's general ledger","when":"always","field":"IdmpKey","severity":"error"},{"rule":"Gift card sales must appear in purchasedGiftCardSummaries and NOT in saleSummary. A gift card sale is a liability until redeemed; posting it as revenue overstates the period and double-counts when the card is spent","when":"always","field":"SaleSummary","severity":"error"},{"rule":"Only the outbound leg of an inter-location transfer belongs in transferSummaries. The receiving Location records the inbound leg in its own summary","when":"always","field":"TransferSummaries","severity":"error"},{"rule":"code must be unique within taxSummaries, feeSummaries and discountSummaries; the pair (code, subType) within tenderSummaries; vendor within purchaseSummaries; type within purchasedGiftCardSummaries; and (code, reasonCode) within adjustmentSummaries","when":"always","field":"TaxSummaries","severity":"error"}],"lifecycle":{"states":["Open","Closed","Posted","PostingFailed","Reversed"],"transitions":[{"to":"Closed","from":"Open","conditions":["Period has ended","No source document in the period is still in a non-final state","roundingError is within tolerance","closedAt and closedBy recorded"]},{"to":"Open","from":"Closed","conditions":["Permission-gated reopen before posting","Reopen recorded in the audit trail"]},{"to":"Posted","from":"Closed","conditions":["External accounting system accepted the summary","externalReference returned and stored","postedAt recorded"]},{"to":"PostingFailed","from":"Closed","conditions":["External system rejected the summary","postingErrors populated"]},{"to":"Closed","from":"PostingFailed","conditions":["Retry initiated; postingErrors retained rather than cleared"]},{"to":"Reversed","from":"Posted","conditions":["A reversing Financial Summary has been posted referencing this one"]}],"initialState":"Open"},"calculations":[{"info":"The close's check on itself. A small residual is expected from per-line rounding; a growing one is the first sign a category is being missed entirely.","name":"roundingError","formula":"SUM(component totals) - SUM(source document totals for the period)","trigger":"On close"},{"info":"Net revenue for the period, excluding gift card sales, which are a liability rather than revenue.","name":"netSales","formula":"saleSummary.taxableAmount + saleSummary.nonTaxableAmount - saleSummary.taxableReturnAmount - saleSummary.nonTaxableReturnAmount","trigger":"query-time"},{"info":"What was actually collected. The gap between this and netSales plus tax and fees is what a close exists to explain.","name":"netTendered","formula":"SUM(tenderSummaries[].takenAmount) - SUM(tenderSummaries[].returnAmount)","trigger":"query-time"},{"info":"Period gross margin at the Location. Cost adjustments are included so margin reflects the cost actually borne, not the cost originally expected.","name":"grossMargin","formula":"netSales - (cogsSummary.taxableAmount + cogsSummary.nonTaxableAmount + cogsSummary.costAdjustmentTaxable + cogsSummary.costAdjustmentNonTaxable)","trigger":"query-time"},{"info":"How many times posting was attempted. A summary that took several attempts is worth being able to find.","name":"postingAttemptCount","formula":"COUNT(postingErrors) + (status = 'Posted' ? 1 : 0)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"saleSummary, discountSummaries, feeSummaries, taxSummaries and tenderSummaries derive from the Sales posted at this Location within the period. A Sale voided after the close must be corrected by a reversing summary, never by recomputing a closed one","entity":"Sale"},{"rule":"cogsSummary and inventorySummary derive from Stock Ledger entries for this Location within the period, at consumed layer cost — so COGS reconciles to inventory valuation rather than to retail price","entity":"Stock Ledger"},{"rule":"Cost adjustment figures across the component summaries derive from Cost Ledger entries in the period","entity":"Cost Ledger"},{"rule":"purchaseSummaries derive from goods received against Purchase Orders in the period, with the invoiced / non-invoiced split resolved through Vendor Invoice matching so accrued but unbilled goods post correctly","entity":"Purchase Order"},{"rule":"transferSummaries derive from Stock Transfers OUT of this Location. The receiving Location's summary records the inbound leg","entity":"Stock Transfer"},{"rule":"adjustmentSummaries derive from Stock Adjustments at this Location, grouped by reason code with positive and negative movements kept separate","entity":"Stock Adjustment"},{"rule":"purchasedGiftCardSummaries derive from Gift Cards issued in the period. These are liabilities and must not be posted as revenue","entity":"Gift Card"},{"rule":"depositSummary and creditMemoSummary derive from Customer Credit activity in the period","entity":"Customer Credit"},{"rule":"The summary covers exactly one Location and must not be consolidated across locations before posting — Location is the dimension most general ledgers post against","entity":"Location"}]},"inlineSchemas":[{"name":"FeeSummary","info":"Fees charged for one fee code, split by taxability.","properties":[{"info":"Fee code. Unique within feeSummaries.","name":"code","type":"string","required":true},{"info":"Human-readable fee description.","name":"description","type":"string","required":true},{"info":"Non-taxable fee amount.","name":"nonTaxableAmount","type":"decimal","required":true},{"info":"Taxable fee amount.","name":"taxableAmount","type":"decimal","required":true}]},{"name":"TaxSummary","info":"Tax collected for one tax code. The merchant's own liability.","properties":[{"info":"Tax code. Unique within taxSummaries.","name":"code","type":"string","required":true},{"info":"Human-readable tax description.","name":"description","type":"string"},{"info":"Tax collected on sales and fees under this code.","name":"salesAndFees","type":"decimal","required":true}]},{"name":"CogsSummary","info":"Cost of goods sold, split by taxability of the underlying sale, with cost adjustments applied to each side.","properties":[{"info":"Cost adjustments against non-taxable COGS.","name":"costAdjustmentNonTaxable","type":"decimal","required":true},{"info":"Cost adjustments against taxable COGS.","name":"costAdjustmentTaxable","type":"decimal","required":true},{"info":"COGS on non-taxable sales.","name":"nonTaxableAmount","type":"decimal","required":true},{"info":"COGS on taxable sales.","name":"taxableAmount","type":"decimal","required":true}]},{"name":"SaleSummary","info":"Gross sales and returns for the period, split by taxability. All amounts to 4 decimal places.","properties":[{"info":"Non-taxable sales.","name":"nonTaxableAmount","type":"decimal","required":true},{"info":"Non-taxable returns.","name":"nonTaxableReturnAmount","type":"decimal","required":true},{"info":"Taxable sales.","name":"taxableAmount","type":"decimal","required":true},{"info":"Taxable returns.","name":"taxableReturnAmount","type":"decimal","required":true}]},{"name":"TenderSummary","info":"Amounts taken and returned for one tender type. The cash-side counterpart to SaleSummary.","properties":[{"info":"Tender code. Unique within tenderSummaries together with subType.","name":"code","type":"string","required":true},{"info":"Human-readable tender description.","name":"description","type":"string","required":true},{"info":"Amount returned in this tender.","name":"returnAmount","type":"decimal","required":true},{"info":"Tender subtype (e.g. card brand).","name":"subType","type":"string"},{"info":"Amount taken in this tender.","name":"takenAmount","type":"decimal","required":true},{"info":"Tender type grouping.","name":"type","type":"string"}]},{"name":"DepositSummary","info":"Customer deposits taken and returned.","properties":[{"info":"Deposits returned.","name":"returnAmount","type":"decimal","required":true},{"info":"Deposits applied against purchases.","name":"usedAmount","type":"decimal","required":true}]},{"name":"DiscountSummary","info":"Discounts given under one discount code, split by taxability.","properties":[{"info":"Discount code. Unique within discountSummaries.","name":"code","type":"string"},{"info":"Human-readable discount description.","name":"description","type":"string"},{"info":"Non-taxable discount amount.","name":"nonTaxableAmount","type":"decimal"},{"info":"Taxable discount amount.","name":"taxableAmount","type":"decimal"}]},{"name":"PurchaseSummary","info":"Purchases from one vendor. The invoiced / non-invoiced split on cost adjustments is what allows accrued but unbilled goods to post correctly.","properties":[{"info":"Purchase cost for the period.","name":"cost","type":"decimal","required":true},{"info":"Cost adjustments on invoiced purchases.","name":"costAdjustmentInvoiced","type":"decimal","required":true},{"info":"Cost adjustments on purchases not yet invoiced — the accrual side.","name":"costAdjustmentNonInvoiced","type":"decimal","required":true},{"info":"Cost of goods returned to the vendor.","name":"returnCost","type":"decimal","required":true},{"info":"Vendor identifier. Unique within purchaseSummaries.","name":"vendor","type":"string"}]},{"name":"TransferSummary","info":"Stock transferred OUT of this Location. The receiving Location records the inbound leg in its own summary, which prevents an inter-location transfer being counted twice across the Company.","properties":[{"info":"Cost adjustment arising on the transfer.","name":"costAdjustment","type":"decimal","required":true},{"info":"Value transferred out.","name":"outAmount","type":"decimal","required":true},{"info":"Stock Transfer Reason.","name":"reason","type":"string"}]},{"name":"InventorySummary","info":"Inventory value movement over the period, split by taxability with return counterparts.","properties":[{"info":"Non-taxable inventory movement.","name":"nonTaxableAmount","type":"decimal","required":true},{"info":"Non-taxable inventory returns.","name":"nonTaxableReturnAmount","type":"decimal","required":true},{"info":"Taxable inventory movement.","name":"taxableAmount","type":"decimal","required":true},{"info":"Taxable inventory returns.","name":"taxableReturnAmount","type":"decimal","required":true}]},{"name":"AdjustmentSummary","info":"Stock adjustments for one reason code. Positive and negative movements are kept separate deliberately — netting them would hide shrink inside a favourable recount.","properties":[{"info":"Adjustment code. Unique within adjustmentSummaries together with reasonCode.","name":"code","type":"string","required":true},{"info":"Cost impact of the adjustments.","name":"costAdjustmentAmount","type":"decimal","required":true},{"info":"Human-readable adjustment description.","name":"description","type":"string","required":true},{"info":"Value of downward adjustments. Never netted against positiveAmount.","name":"negativeAmount","type":"decimal","required":true},{"info":"Value of upward adjustments.","name":"positiveAmount","type":"decimal","required":true},{"info":"Stock Adjustment Reason code.","name":"reasonCode","type":"string"},{"info":"Human-readable reason.","name":"reasonDescription","type":"string"}]},{"name":"CreditMemoSummary","info":"Credit memos issued and returned, split by taxability.","properties":[{"info":"Non-taxable credit memo amount.","name":"nonTaxableAmount","type":"decimal","required":true},{"info":"Non-taxable credit memo returns.","name":"nonTaxableReturnAmount","type":"decimal","required":true},{"info":"Taxable credit memo amount.","name":"taxableAmount","type":"decimal","required":true},{"info":"Taxable credit memo returns.","name":"taxableReturnAmount","type":"decimal","required":true}]},{"name":"DrawerMemoSummary","info":"Non-sale cash movements at the till — what a store manager reconciles physical cash against.","properties":[{"info":"Resulting drawer balance.","name":"balance","type":"decimal","required":true},{"info":"Description of the drawer activity.","name":"description","type":"string","required":true},{"info":"Cash paid into the drawer outside of sales.","name":"paidIn","type":"decimal","required":true},{"info":"Cash paid out of the drawer outside of sales.","name":"paidOut","type":"decimal","required":true}]},{"name":"PurchasedGiftCardSummary","info":"Gift cards sold, by type, split by taxability. Kept out of SaleSummary because a gift card sale is a LIABILITY, not revenue — posting it as revenue overstates the period and double-counts on redemption.","properties":[{"info":"Non-taxable gift card sales.","name":"nonTaxableAmount","type":"decimal","required":true},{"info":"Non-taxable gift card returns.","name":"nonTaxableReturnAmount","type":"decimal","required":true},{"info":"Taxable gift card sales.","name":"taxableAmount","type":"decimal","required":true},{"info":"Taxable gift card returns.","name":"taxableReturnAmount","type":"decimal","required":true},{"info":"Gift card type. Unique within purchasedGiftCardSummaries.","name":"type","type":"string","required":true}]}]},{"name":"Franchise Group","class":"Dictionary","subsystem":"CONNECT","area":"Organization","desc":"Organizational grouping of retail locations under a common franchise operator or ownership entity (including corporate-owned divisions requiring data segmentation). The unit of Franchise Group sub-tenancy — a key capability of Company: every sanitizable document carries a franchiseGroups array, Users are assigned to zero or more groups, and reads return only documents whose groups overlap the User's assignments plus GLOBAL-scoped documents. Soft, user-scoped visibility filtering below the hard Company boundary; never an infrastructure or auth boundary. Formal capability definition: connect-franchise-group-subtenancy-capability.md (FGT-R1–R12); enforcement: Franchise Governance Subsystem (FGS, renamed 2026-09-18 from Franchise Sanitization Subsystem/FSS).","status":"draft","properties":[{"n":"billingAddress","t":"valueType","vt":"Address"},{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"The Company (tenant) this franchise group belongs to. Each Franchise Group is assigned to exactly one Company — required, many-to-one. Reinforces the tenancy model: Company is the hard tenant boundary, Franchise Group is a sub-tenant grouping within it, and the 'code unique across the Company' rule is scoped by this relation. Converted from entityRef to entityDetail on 03 Sep 2026 — it was the last entity in the registry referencing Company by plain reference. Franchise Group is denormalized onto every sanitizable document as an entityDetail, so a franchise group carrying its own tenant only by id was the one hop in the sanitization chain that still required a lookup."},{"n":"configurationId","r":true,"t":"entityRef","re":"Config","info":"Reference to this Franchise Group's Config record in the CONFIG subsystem (Config Service), mirroring Company.configurationId. Holds franchise-group-scoped settings/overrides that apply to data within this group (e.g. assignment-rule config, feature flags, integration defaults) layered beneath the Company's configuration. Keeps configuration in the Config Service rather than inline on the entity."},{"n":"description","t":"string"},{"n":"name","r":true,"t":"string","u":true},{"n":"postalAddress","t":"valueType","vt":"Address"},{"n":"territories","r":true,"t":"array","re":"Territory","info":"Geographic territories assigned to this franchise group. Each element conforms to the Territory inline schema: a Market plus the postal/ZIP codes within that market that belong to this group. Used to resolve franchise-group assignment for address/geo-scoped documents (e.g. Customer) by matching a document's market + postal code against these territories. Empty array = no territory-based assignment for this group."}],"ext":"LookupEntity","related":["Location","Company","Market","Config"],"bv":{"rules":[{"rule":"Must be unique across the Company","when":"create","field":"Code","severity":"error"}],"lifecycle":null,"calculations":[{"name":"locationCount","formula":"COUNT(Location WHERE franchiseGroups CONTAINS this)","trigger":"query-time"},{"name":"userCount","formula":"COUNT(User WHERE franchiseGroups CONTAINS this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Cannot delete while Locations are assigned","entity":"Location"}]},"inlineSchemas":[{"name":"Territory","properties":[{"info":"The Market this territory falls within. Scopes the postal/ZIP codes and provides currency/tax/catalog context.","name":"market","type":"entityRef","required":true,"relatedEntity":"Market"},{"info":"Postal/ZIP codes within the market that define this territory (string values). Matched against a document's postal code to resolve franchise-group assignment. Empty array = the whole market with no postal-code narrowing.","name":"postalCodes","type":"array","required":true}],"description":"A geographic territory assigned to a franchise group, defined by a Market and the postal/ZIP codes within that market."}]},{"name":"Fulfillment","class":"Transactional","subsystem":"CONNECT","area":"Sales & Orders","desc":"Process of picking, packing, and shipping items to fulfill a customer order.","status":"stub","properties":[{"n":"fulfillmentId","r":true,"t":"string"},{"n":"fulfillmentOrder","r":true,"t":"entityRef","re":"Fulfillment Order"},{"n":"lines","r":true,"t":"array","info":"Array of FulfillmentLine sub-documents. Each line records the quantity fulfilled for an item."}],"ext":"TransactionalDocument","shopify":"Fulfillment resource","related":["Fulfillment Order","Shipment"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Fulfillment Order","class":"Order","subsystem":"CONNECT","area":"Sales & Orders","desc":"Request or instruction to fulfill specific items from an order, potentially routed to different locations.","status":"stub","properties":[{"n":"fulfillmentOrderId","r":true,"t":"string"},{"n":"lines","r":true,"t":"array","info":"Array of FulfillmentOrderLine sub-documents. Each line tracks pick/pack progress for an item."},{"n":"location","r":true,"t":"entityDetail","re":"Location"},{"n":"salesOrder","r":true,"t":"entityRef","re":"Sales Order"}],"ext":"OperationalDocument","shopify":"FulfillmentOrder resource","related":["Sales Order","Location","Fulfillment","Ship Order"],"bv":{"rules":[],"lifecycle":{"states":["New","InProgress","Fulfilled","Cancelled"],"transitions":[{"to":"InProgress","from":"New","conditions":["Items being picked"]},{"to":"Fulfilled","from":"InProgress","conditions":["All items packed and shipped/ready"]},{"to":"Cancelled","from":"New","conditions":[]}],"initialState":"New"},"calculations":[{"name":"lineCount","formula":"COUNT(lines)","trigger":"query-time"},{"name":"shipOrderCount","formula":"COUNT(Ship Order WHERE fulfillmentOrder = this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Must reference a valid Sales Order","entity":"Sales Order"},{"rule":"Generates Shipment records for delivery","entity":"Shipment"}]}},{"name":"Gift Card","class":"Balance","subsystem":"CONNECT","area":"Payments & Stored Value","desc":"A stored-value instrument issued and redeemed at Sale.","status":"stub","properties":[{"n":"balance","r":true,"t":"decimal"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. Load-bearing here beyond ordinary isolation: a gift card is a liability redeemable for money, and a balance readable across the tenant boundary is a direct financial exposure rather than a privacy issue."},{"n":"expiryDate","t":"date"},{"n":"giftCardNo","r":true,"t":"string"},{"n":"issuedDate","r":true,"t":"date"}],"shopify":"GiftCard resource","related":["Customer","Sale"],"bv":{"rules":[{"rule":"Must be >= 0 at all times","when":"always","field":"Balance","severity":"error"},{"rule":"Must be after IssuedDate","when":"create","field":"ExpiryDate","severity":"error"}],"lifecycle":{"states":["Active","Depleted","Expired","Cancelled"],"transitions":[{"to":"Depleted","from":"Active","conditions":["Balance reaches zero"]},{"to":"Expired","from":"Active","conditions":["ExpiryDate passed"]},{"to":"Cancelled","from":"Active","conditions":["Manual cancellation"]}],"initialState":"Active"},"calculations":[],"crossEntityConstraints":[{"rule":"Payment against Gift Card must not exceed current balance","entity":"Payment"}]}},{"name":"Goods Receipt","class":"Transactional","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"Confirms physical receipt of goods against a PO or ASN. Triggers inventory increase.","status":"draft","properties":[{"n":"asn","t":"entityRef","re":"Advanced Shipping Notice"},{"n":"fees","r":true,"t":"array","info":"Receipt-level fees."},{"n":"fiscalDate","t":"datetime","info":"Fiscal date for accounting period assignment."},{"n":"isMatched","r":true,"t":"boolean","info":"Whether this receipt has been matched to a purchase order for three-way matching."},{"n":"lines","r":true,"t":"array","info":"Array of GoodsReceiptLine sub-documents. Each line records a received Item, quantity, and cost against a Purchase Order."},{"n":"location","r":true,"t":"entityDetail","re":"Location"},{"n":"postedDate","t":"datetime","info":"Date the receipt was posted and inventory updated."},{"n":"purchaseOrder","r":true,"t":"entityRef","re":"Purchase Order"},{"n":"purchaseReceiptDate","r":true,"t":"datetime","info":"Date the goods were physically received."},{"n":"purchaseReceiptNo","r":true,"t":"string"},{"n":"reversedPurchaseReceiptId","t":"string","info":"ID of the receipt this reverses, if this is a reversal."},{"n":"reversingPurchaseReceiptId","t":"string","info":"ID of the reversal receipt, if this receipt has been reversed."},{"n":"status","r":true,"t":"schema","info":"GoodsReceiptStatus inline schema with documentStatus and operationalStatus."},{"n":"type","r":true,"t":"enumeration","v":["Purchase","Return"],"info":"Receipt type — Purchase for incoming goods, Return for vendor returns."},{"n":"vendor","r":true,"t":"entityDetail","re":"Vendor","info":"The vendor supplying the goods."},{"n":"vendorInvoiceId","t":"string","info":"Reference to associated vendor invoice for matching."}],"ext":"TransactionalDocument","related":["Purchase Order","Advanced Shipping Notice","Location","Stock Ledger"],"bv":{"rules":[{"rule":"Cannot exceed PO line outstanding quantity unless over-receive is enabled","when":"always","field":"ReceivedQty","severity":"error"}],"lifecycle":{"states":["Draft","Posted"],"transitions":[{"to":"Posted","from":"Draft","conditions":["All received quantities validated","Stock Ledger entries written"]}],"initialState":"Draft"},"calculations":[],"crossEntityConstraints":[{"rule":"Must reference a valid open or partially received PO","entity":"Purchase Order"},{"rule":"Posting writes Stock Ledger entries for received items","entity":"Stock Ledger"},{"rule":"Posting increments Item Stock at the receiving Location","entity":"Item Stock"}]},"inlineSchemas":[{"name":"GoodsReceiptStatus","extends":"OperationalSubDocument","properties":[{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"},{"info":"Current document lifecycle status.","name":"documentStatus","type":"enumeration","values":["Draft","Processing","Posted"],"required":true},{"info":"Operational sub-status for reversal tracking.","name":"operationalStatus","type":"enumeration","values":["Reversed","Complete","Reversal"]}]}]},{"name":"Hs Code","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Harmonized System (HS) tariff classification code maintained by the World Customs Organization. Hierarchical 6–10 digit codes used for customs declarations, duty rate determination, and trade compliance. The first 6 digits are internationally standardized; digits 7–10 are country-specific extensions.","status":"draft","properties":[{"n":"chapter","r":true,"t":"string","info":"First 2 digits — the HS chapter (e.g. 61 = Articles of apparel, knitted or crocheted)."},{"n":"heading","r":true,"t":"string","info":"Digits 3–4 — the HS heading within the chapter (e.g. 6109 = T-shirts, singlets, and other vests)."},{"n":"subheading","t":"string","info":"Digits 5–6 — the HS subheading (e.g. 610910 = Of cotton). Null for chapter/heading-level codes."},{"n":"countryExtension","t":"string","info":"Digits 7–10 — country-specific tariff schedule extension (e.g. HTS for US, CN for EU). Null for internationally-standardized 6-digit codes."},{"n":"dutyRate","t":"decimal","info":"Default duty rate percentage for this classification. May be overridden by trade agreements or country-specific schedules."},{"n":"country","t":"entityDetail","re":"Country","info":"The country whose tariff schedule this extended code belongs to. Null for universal 6-digit HS codes."}],"ext":"LookupEntity","related":["Product","Country"]},{"name":"Inventory Position","class":"Balance","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"Authoritative running inventory balance and valuation per Item × Location, maintained by the Stock Ledger Service by folding Stock Ledger entries in ledgerLine order. This is the source of truth behind Item Stock's cached cost and quantity fields — published to CONNECT via InventoryPositionChanged and CostRecalculated events. Mutable by design (recomputed on every posting); the immutable history lives in the Stock Ledger. Under FIFO/LIFO, valuation derives from Stock Cost Layer state; under WAC, from the running average maintained here.","status":"draft","properties":[{"c":true,"n":"baseTotalCost","t":"decimal","info":"totalCost converted to the Company's base/reporting currency using per-entry captured exchange rates. Null when currencyCode equals Company.baseCurrencyCode. Cached onto Item Stock.baseTotalCost for consolidated valuation reports."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — hard isolation boundary for queries, events, and replication."},{"n":"costingMethod","r":true,"t":"enumeration","v":["FIFO","LIFO","WAC"],"info":"Effective costing method for this Item × Location. Inherits from Company.defaultCostingMethod on creation; overridable per the Stock Ledger Service PRD. Changing after entries have posted requires an offline replay job."},{"n":"currencyCode","r":true,"t":"string","info":"ISO 4217 currency code for unitCost and totalCost. Mirrored by Item Stock.currencyCode."},{"n":"item","r":true,"t":"entityRef","re":"Item","info":"The Item (SKU) this position tracks."},{"c":true,"n":"lastLedgerLine","r":true,"t":"integer","info":"ledgerLine of the most recent Stock Ledger entry folded into this position. Idempotency high-water mark — entries are applied only when their ledgerLine exceeds this value, making projection and event replay safe."},{"n":"location","r":true,"t":"entityDetail","re":"Location","info":"The Location this position tracks."},{"c":true,"n":"qtyOnHand","r":true,"t":"integer","info":"Running SUM(qty) of all Stock Ledger entries for this Item × Location. Reconciles with Item Stock onHand and with the latest entry's balanceQty."},{"c":true,"n":"totalCost","t":"decimal","info":"Total cost of on-hand inventory under costingMethod. FIFO/LIFO: Σ(remainingQty × unitCost) across Stock Cost Layers; WAC: qtyOnHand × running average. Cached onto Item Stock.totalCost."},{"c":true,"n":"unitCost","t":"decimal","info":"Current per-unit cost, method-dispatched: FIFO = oldest remaining layer's unitCost; LIFO = newest remaining layer's; WAC = totalCost / qtyOnHand. Cached onto Item Stock.unitCost via InventoryPositionChanged events."}],"service":"Stock Ledger Service","ext":"Identifiable","notes":"Documented 2026-07-24 to make the Stock Ledger Service's projection contract explicit — Item Stock (reviewed) already references InventoryPosition.unitCost/totalCost/baseTotalCost and the InventoryPositionChanged event. lastLedgerLine is the idempotency high-water mark: projections apply an entry only if entry.ledgerLine > lastLedgerLine, making event replay safe. Distinct from Item Stock: Inventory Position is the service-side authoritative balance; Item Stock is CONNECT's enriched cache (adds bins, quantity dimensions, activity dates).","related":["Item","Location","Stock Ledger","Item Stock","Stock Cost Layer"],"bv":{"rules":[{"rule":"qtyOnHand must equal the running SUM(qty) of Stock Ledger entries up to lastLedgerLine (and the balanceQty of that entry)","when":"always","field":"QtyOnHand","severity":"error"},{"rule":"Negative qtyOnHand allowed only when Company settings permit negative inventory","when":"always","field":"QtyOnHand","severity":"error"},{"rule":"One position per Item × Location — the (item, location) pair is unique","when":"create","field":"Item","severity":"error"}],"lifecycle":null,"calculations":[{"name":"qtyOnHand","formula":"SUM(StockLedger.qty) WHERE item AND location match, applied in ledgerLine order","trigger":"On every Stock Ledger posting for the Item × Location"},{"name":"unitCost","formula":"Method-dispatched: FIFO = oldest remaining Stock Cost Layer unitCost; LIFO = newest; WAC = totalCost / qtyOnHand","trigger":"On posting or CostRecalculated"},{"name":"totalCost","formula":"FIFO/LIFO: Σ(remainingQty × unitCost) across Stock Cost Layers; WAC: qtyOnHand × running average","trigger":"On posting or CostRecalculated"},{"name":"baseTotalCost","formula":"totalCost converted using per-entry captured exchange rates (null when currency = base)","trigger":"On posting or CostRecalculated"},{"name":"lastLedgerLine","formula":"MAX(StockLedger.ledgerLine) applied to this position","trigger":"On every applied posting"}],"crossEntityConstraints":[{"rule":"Recomputed from Stock Ledger entries in ledgerLine order; every posting updates the position atomically","entity":"Stock Ledger"},{"rule":"Item Stock caches unitCost, totalCost, baseTotalCost, and quantities from this position via InventoryPositionChanged / CostRecalculated events","entity":"Item Stock"}]}},{"name":"Item","class":"Operational","subsystem":"CONNECT","area":"Products & Pricing","desc":"A specific sellable variant of a Product (e.g. size/color combination). Has its own SKU, barcodes, costs, prices, and item-level commerce flags.","status":"draft","properties":[{"n":"allocationStartDate","t":"datetime"},{"n":"attributeValues","r":true,"t":"array","re":"Attribute","info":"AttributeValue entries identifying this variant (e.g. Red / Large / Cotton). Drives the calculated itemName. Required with an empty-array default per array-must-be-required; the Zod schema is nullish today and must be tightened to match (corrected 31 Aug 2026)."},{"n":"availableDate","t":"datetime"},{"n":"barcodes","r":true,"t":"array","info":"Array of ItemBarcode inline schema objects, each with a GTIN-validated barcode string and an isPrimary flag. Required with an empty-array default per array-must-be-required; the Zod schema is nullish today and must be tightened to match (corrected 31 Aug 2026)."},{"n":"basePrice","t":"decimal","info":"[RESTRICTED:price] Item-level override of Product.defaultBasePrice. The sticker price for this variant before price-level overrides. Hidden from holders of portal:product:read unless they also carry portal:product:price:read; mutated only via the portal:product:change-price operation, which posts to Price Ledger. See convention restricted-field-marker."},{"n":"blockDiscount","r":true,"t":"boolean"},{"n":"canBackorder","r":true,"t":"boolean"},{"n":"canPreOrder","r":true,"t":"boolean"},{"c":true,"n":"currentUnitCost","t":"decimal","info":"[RESTRICTED:cost] Calculated, method-agnostic. Quantity-weighted roll-up across all Item Stock rows for this Item of Item Stock.unitCost — the current per-unit inventory value under each row's configured costing method (FIFO = oldest remaining layer, LIFO = newest, WAC = running average). Cached read from the Stock Ledger Service's InventoryPositionChanged events; the Stock Ledger is the source of truth. Null if the item has no Item Stock rows with positive on-hand qty. Hidden from holders of portal:product:read unless they also carry portal:product:cost:read. Calculated, so it has no write path of its own; cost is mutated via the portal:product:change-cost operation, which posts to Cost Ledger. See convention restricted-field-marker."},{"n":"discontinuedDate","t":"datetime","info":"When the retailer stopped purchasing this variant. Null when isDiscontinued is false. UTC. Not the date any individual vendor dropped the line — that is VendorItemValue.discontinuedDate."},{"n":"dropshipEligibility","t":"enumeration","v":["Available","Required","Unavailable"]},{"c":true,"n":"firstPurchasedAt","t":"datetime","info":"Earliest timestamp this Item (SKU/variant) appeared on a purchase order. Named Purchased (not Ordered) to avoid confusion with sales/customer orders. Fold: MIN(locationActivity[].firstPurchasedAt). Ultimate SoR is the PO subsystem (PurchaseOrderLine); reconciles with Item Stock.firstPurchasedAt per location. Null if never purchased."},{"c":true,"n":"firstReceivedAt","t":"datetime","info":"Earliest timestamp this Item (SKU/variant) was received into any location from an external source (movementType Receipt or Return — transfers post as Transfer, tracked via firstTransferredAt). Fold: MIN(locationActivity[].firstReceivedAt). Ultimate SoR is the Stock Ledger; reconciles with Item Stock.firstReceivedAt per location. Null if never received."},{"c":true,"n":"firstSoldAt","t":"datetime","info":"Earliest timestamp this Item (SKU/variant) was sold at any location (movementType Sale — Sales Channel is an attribute of the sale, not a movement type). Fold: MIN(locationActivity[].firstSoldAt). Ultimate SoR is the Stock Ledger; reconciles with Item Stock.firstSoldAt per location. Null if never sold."},{"c":true,"n":"firstTransferredAt","t":"datetime","info":"Earliest timestamp this Item (SKU/variant) was transferred in or out of any location (movementType Transfer — paired entries, direction via qty sign, both directions count). Fold: MIN(locationActivity[].firstTransferredAt). Ultimate SoR is the Stock Ledger; reconciles with Item Stock.firstTransferredAt per location. Kept separate from firstReceivedAt so transfer activity is composable in or out of recency analysis. Null if never transferred."},{"n":"isDiscontinued","r":true,"t":"boolean","info":"Whether the retailer has stopped PURCHASING this variant from any vendor. A discontinued Item can still be sold and stays visible in the Sales Channels it is published to — it simply cannot be ordered again. This is the sell-through state. Distinct from BOTH neighbours: VendorItemValue.isDiscontinued means one Vendor stopped supplying it while others may still, and status = Archived means the Item can no longer be sold at all. Clarified 31 Aug 2026."},{"n":"isFinalSale","r":true,"t":"boolean"},{"n":"isLrpEligible","r":true,"t":"boolean","info":"Eligible for loyalty rewards program."},{"n":"isReplenishment","r":true,"t":"boolean"},{"n":"itemName","t":"string","info":"Calculated field. Concatenation of the currently set AttributeValue names on this item separated by a space (e.g. 'Red Large Cotton')."},{"n":"itemNo","r":true,"t":"string","u":true,"info":"Human-readable identifier for the item. Required and unique within Company, per entity-no-property convention. Uniqueness declared 31 Aug 2026 — the convention expected it and the flag was absent."},{"c":true,"n":"lastActivityAt","t":"datetime","info":"Most recent activity for this Item across all locations, transfers excluded: MAX(lastPurchasedAt, lastReceivedAt, lastSoldAt). Equivalently MAX(locationActivity[].lastActivityAt). Transfer-inclusive recency = MAX(lastActivityAt, lastTransferredAt) at query time. Recency checks evaluate against a configurable window (e.g. Company.activityRecencyDays). For per-location recency, query locationActivity."},{"c":true,"n":"lastPurchasedAt","t":"datetime","info":"Most recent timestamp this Item (SKU/variant) appeared on a purchase order. Named Purchased (not Ordered) to avoid confusion with sales/customer orders. Fold: MAX(locationActivity[].lastPurchasedAt). Ultimate SoR is the PO subsystem (PurchaseOrderLine); reconciles with Item Stock.lastPurchasedAt per location. Null if never purchased."},{"c":true,"n":"lastPurchasedCost","t":"decimal","info":"[RESTRICTED:cost] Calculated field. Roll-up of VendorItemValue.lastPurchasedCost across all vendors for this Item — the unit cost from the single most recent Purchase Order line (regardless of vendor). Named Purchased (not Ordered) to avoid confusion with sales/customer orders. Null if the item has never been purchased. For the per-vendor value, see VendorItemValue.lastPurchasedCost. Renamed from lastOrderedCost on 2026-07-24. Hidden from holders of portal:product:read unless they also carry portal:product:cost:read. See convention restricted-field-marker."},{"c":true,"n":"lastReceivedAt","t":"datetime","info":"Most recent timestamp this Item (SKU/variant) was received into any location from an external source (movementType Receipt or Return — transfers post as Transfer, tracked via lastTransferredAt). Fold: MAX(locationActivity[].lastReceivedAt). Ultimate SoR is the Stock Ledger; reconciles with Item Stock.lastReceivedAt per location. Null if never received."},{"c":true,"n":"lastReceivedCost","t":"decimal","info":"[RESTRICTED:cost] Calculated field. Roll-up of VendorItemValue.lastReceivedCost across all vendors for this Item — the unit cost from the single most recent received inventory transaction (regardless of vendor). Sourced from the Stock Ledger (movementType Receipt or Return), which is the system of record for received cost; the Cost Ledger records agreed vendor cost and is NOT a source for this field (corrected 2026-08-31). Vendor attribution resolves through the receipt's sourceEntityId → Goods Receipt → Purchase Order. Null if the item has never been received. For the per-vendor value, see VendorItemValue.lastReceivedCost. Slated for migration to vendorActivity[].lastReceivedAmount per registry question #11. Hidden from holders of portal:product:read unless they also carry portal:product:cost:read. See convention restricted-field-marker."},{"c":true,"n":"lastSoldAt","t":"datetime","info":"Most recent timestamp this Item (SKU/variant) was sold at any location (movementType Sale — Sales Channel is an attribute of the sale, not a movement type). Fold: MAX(locationActivity[].lastSoldAt). Ultimate SoR is the Stock Ledger; reconciles with Item Stock.lastSoldAt per location. Null if never sold."},{"c":true,"n":"lastTransferredAt","t":"datetime","info":"Most recent timestamp this Item (SKU/variant) was transferred in or out of any location (movementType Transfer — paired entries, direction via qty sign, both directions count). Fold: MAX(locationActivity[].lastTransferredAt). Ultimate SoR is the Stock Ledger; reconciles with Item Stock.lastTransferredAt per location. Kept separate from lastReceivedAt so transfer activity is composable in or out of recency analysis. Null if never transferred."},{"c":true,"n":"locationActivity","r":true,"t":"array","re":"Location","info":"Array of ItemLocationActivity inline schema objects — per-location lifecycle/activity dates at Item × Location grain. Required; defaults to [] at creation, entries created lazily on first activity at a Location. location unique within the array. Exists so dates are available consistently on Product and child Items without depending on Item Stock (which may be used independently of the product/item entities). Maintained from the same upstream events as Item Stock; Item Stock rows reconcile against these entries. The Item-level all-location scalars are folds (MIN/MAX) over this array; Product.locationActivity[location] aggregates these entries across the Product's child Items."},{"n":"measurements","t":"schema","info":"Item-level override of Product.defaultMeasurements. Variant-specific height/length/width/weight. Nullish — when unset, the Item inherits Product.defaultMeasurements at read time. Shape defined by the ItemMeasurements inline schema."},{"n":"media","r":true,"t":"array","info":"Array of ItemMedia inline schema objects. Each entry references a Media asset (image, thumbnail or video) associated with the item. Required with an empty-array default per array-must-be-required (corrected 31 Aug 2026). Entries must reference Media with mediaType in (Image, Video) and purpose='ProductMedia' — see the Media cross-entity constraint."},{"n":"optionValues","t":"array<OptionValue>","re":"Option","info":"Array of OptionValue objects ({ code: string, name: string|null, option: EntityDetail→Option, aliases: string[]|null, priceModifier: decimal|null, vendorCostModifiers: VendorOptionCostModifier[]|null }). Each VendorOptionCostModifier is { costModifier: decimal, vendor: EntityDetail→Vendor }. Price modifiers are flat; cost modifiers are per-vendor. IMPORTANT: These are relative deltas representing the impact of selecting this option on line-level price/cost (e.g. +$5 for engraving). They do NOT create entries in the Price Ledger or Cost Ledger. Contrast with the Price Adjustment and Cost Adjustment transactional entities which DO write to ledgers. Nullish in Zod — not required at creation time."},{"n":"prices","t":"array<ItemPrice>","re":"Price Level","info":"[RESTRICTED:price] Array of ItemPrice inline schema objects. Overrides Product.defaultPrices for this Item. One entry per Price Level. Nullish in Zod — not required at creation time. Hidden from holders of portal:product:read unless they also carry portal:product:price:read; mutated only via the portal:product:change-price operation, which posts to Price Ledger. See convention restricted-field-marker."},{"n":"salesChannels","t":"array<SalesChannelItemValue>","re":"Sales Channel","info":"Per-channel publish state for this item. Nullish in Zod — not required at creation time."},{"n":"salesVelocity","t":"enumeration","v":["High","Medium","Low"]},{"n":"sku","t":"string","u":true,"info":"The retailer's own stock-keeping unit code for this variant. Unique within Company. Deliberately NOT required: the lifecycle allows an Item to exist in Draft without a SKU, and assignment is a condition of the Draft to Active transition — so uniqueness is enforced on any non-null value rather than at create. Distinct from VendorItemValue.sku, which is the vendor's code for the same variant and is neither unique nor ours. Uniqueness flag declared 31 Aug 2026; the constraint already existed as a validation rule."},{"n":"status","r":true,"t":"schema","info":"Lifecycle status of the item. Tracks current state and who/when it was last changed."},{"n":"stockLimits","t":"array<StockLimitItemValue>","re":"Stock Limit Group","info":"Per stock-limit-group min/max thresholds by location and period. Nullish in Zod — not required at creation time."},{"c":true,"n":"vendorActivity","r":true,"t":"array","re":"Vendor","info":"Array of ItemVendorActivity inline schema objects — per-vendor activity dates at Item × Vendor grain (purchase/receipt legs only; sales and transfers have no vendor dimension). Required; defaults to []; entries created lazily on first activity with a vendor. vendor unique within the array. Deliberately separate from vendors[] (current sourcing config): entries are NEVER deleted on vendor unassignment, preserving historical activity across vendor changes. Aggregates up to Product.vendorActivity[vendor] across the Product's child Items. Amount fields to be added by the deferred amounts request (registry question #11)."},{"n":"vendors","t":"array&lt;VendorItemValue&gt;","re":"Vendor","info":"Array of VendorItemValue inline schema objects carrying per-variant commercial values for vendors already linked to the parent Product. STRICT SUBSET of Product.vendors — the lists must not diverge, and an entry here can never introduce a Vendor the Product is not sourced from. Holds only what varies per SKU (vendor SKU, barcodes, cost, pack size, unit of measure, dropship, per-vendor discontinuation); the sourcing relationship itself — including which vendor is primary and which each Franchise Group prefers — lives solely on Product. Absence of an entry means INHERIT the Product-level cost, never that the vendor does not supply the variant; to express that, add an entry with isDiscontinued = true. Scope clarified and isPrimary removed 31 Aug 2026."},{"n":"weeksOfSupply","t":"integer","info":"Item-level override of Product.defaultWeeksOfSupply. Resolution: effectiveValue = Item.weeksOfSupply ?? Product.defaultWeeksOfSupply. Null means RESOLVE FROM THE PARENT at read time — not zero, and not a value copied at Item creation, so a change to the Product moves every Item that has not set this explicitly. Declared in inheritance.overridableProperties on 03 Sep 2026 when the parent was renamed to defaultWeeksOfSupply; previously both sides described the inheritance but neither entity declared it, so overridable-default-prefix and child-override-matches-parent were both silent. Same scalar-fallback shape as basePrice."}],"ext":"OperationalDocument","shopify":"ProductVariant resource","notes":"Vendor model tightened and lifecycle semantics clarified 31 Aug 2026.\n\nVENDOR MODEL — the governing principle is that Product.vendors owns the sourcing RELATIONSHIP and Item.vendors owns only what varies per SKU.\n\nREMOVED: preferredVendors (property and its ItemPreferredVendor inline schema). Preferred vendor is a per-Franchise-Group designation about who is buying; the variant is about what is bought. Those are orthogonal, so the designation never varied by Item. It resolves from Product.preferredVendors alone. This also cleared two standing registry errors (overridable-default-prefix on Product, child-override-matches-parent here) without the rename to defaultPreferredVendors that would otherwise have been required — a rename that would have entrenched an inheritance relationship that does not exist.\n\nREMOVED: VendorItemValue.isPrimary. Primary vendor is a relationship fact and is always the Product's; restating it per variant created a second source of truth that could silently disagree with ProductVendor.isPrimary.\n\nADDED: VendorItemValue.isDiscontinued and discontinuedDate. Vendor discontinuation is genuinely per Vendor × Item and had no home.\n\nCLARIFIED: Item.vendors is a strict subset of Product.vendors and the lists must not diverge. Absence of an entry means inherit the Product-level cost, NOT that the vendor does not supply the variant; non-supply is expressed by an entry with isDiscontinued = true. Both readings were previously supported by the registry text and they are opposites, so the ambiguity was live. Considered and rejected: a sourcingStatus enum (Active / Discontinued / NotCarried) — the never-carried vs no-longer-carried distinction was judged not operationally relevant, since both mean the same thing at PO time.\n\nTHREE-LEVEL GOING-AWAY SEMANTICS, previously conflated and now written down explicitly:\n1. VendorItemValue.isDiscontinued — ONE Vendor stopped supplying this variant. Others may still. Blocks sourcing from that vendor only.\n2. Item.isDiscontinued — the RETAILER stopped purchasing this variant from any vendor. The Item REMAINS SELLABLE and stays visible in its published Sales Channels; it simply cannot be ordered again. This is the sell-through state.\n3. status = Archived — the Item can neither be sold nor ordered. Strictly stronger than isDiscontinued, and reachable only when qty on hand is <= 0 across all Locations.\n\nThe normal path is therefore discontinue → sell through to zero → archive. The on-hand <= 0 requirement existed only as a cross-entity constraint and is now also a transition condition on the state machine, where it belongs.\n\nStill open: folding preferredVendors into ProductVendor so the containment constraint becomes structural rather than validated. Logged as a registry question.","related":["Product","Item Stock","Price Level","Vendor","Sales Channel","Stock Limit Group","Franchise Group"],"bv":{"rules":[{"rule":"Required and unique within Company","when":"always","field":"ItemNo","severity":"error"},{"rule":"Unique within Company on any non-null value. Not required at create — an Item may sit in Draft without a SKU, and assignment is a condition of the Draft to Active transition","when":"always","field":"SKU","severity":"error"},{"rule":"A barcode value must RESOLVE TO EXACTLY ONE ITEM across the Company, spanning both Item.barcodes and every VendorItemValue.barcodes entry on every Item. Duplicates WITHIN a single Item are legal and common — the retailer's code and a vendor's are frequently the same manufacturer GTIN, and two vendors supplying the same branded item will share one. Duplicates ACROSS different Items are the error. Corrected 31 Aug 2026 from an earlier rule that forbade any repeat anywhere, which would have rejected the normal case","when":"always","field":"barcodes","severity":"error"},{"rule":"Each Item.barcodes value must conform to GTIN-8, GTIN-12, GTIN-13 or GTIN-14. Replaces a stale rule that referenced a UPC field this entity does not have","when":"always","field":"barcodes","severity":"error"},{"rule":"VendorItemValue.barcodes values should conform to GTIN but are not required to — vendors legitimately print proprietary codes in Code 128 or their own SKU symbology. Warning rather than error, unlike the retailer-side rule","when":"always","field":"vendors","severity":"warning"},{"rule":"At most one entry per barcode list may have isPrimary = true. Scoped to the list, not the Item — the retailer's primary and each vendor's primary are separate designations and need not agree","when":"always","field":"barcodes","severity":"error"},{"rule":"VendorItemBarcode.packQty must equal 1 when packLevel = 'Each' and be greater than 1 otherwise. A case or inner code that resolves to a single unit would under-receive every carton scanned","when":"always","field":"vendors","severity":"error"},{"rule":"A vendor's packLevel/packQty codes must be consistent with that vendor's unitOfMeasure and unitOfMeasureQty — the former governs how a receiving scan converts to units, the latter how the line is ordered, and they must describe the same physical packaging","when":"always","field":"vendors","severity":"warning"},{"rule":"Every vendors[].vendor must be present in Product.vendors[].vendor — Item.vendors is a strict subset of the parent Product's vendor list and the two must not diverge","when":"always","field":"vendors","severity":"error"},{"rule":"vendor must be unique within vendors — at most one entry per Vendor","when":"always","field":"vendors","severity":"error"},{"rule":"Absence of a vendors[] entry means inherit the Product-level cost for that Vendor; it never means the Vendor does not supply this variant. Non-supply is expressed by an entry with isDiscontinued = true","when":"read","field":"vendors","severity":"info"},{"rule":"A vendors[] entry with isDiscontinued = true is excluded from sourcing resolution but is never deleted — historical costs stay readable","when":"always","field":"vendors","severity":"info"},{"rule":"Marks that the retailer will no longer PURCHASE this variant from any vendor. The Item remains sellable and stays visible in its published Sales Channels — setting this must not unpublish the Item or block a Sale. It blocks new Purchase Order lines only","when":"always","field":"IsDiscontinued","severity":"error"},{"rule":"Must NOT be set merely because a vendor discontinued supply — that is VendorItemValue.isDiscontinued, and another vendor may still supply the variant","when":"update","field":"IsDiscontinued","severity":"error"},{"rule":"When every vendors[] entry is isDiscontinued but the Item is not, the variant is unorderable without being marked so — surface for review rather than setting it automatically, since the retailer may intend to add a new vendor","when":"always","field":"IsDiscontinued","severity":"warning"},{"rule":"Cannot transition to Archived while total qty on hand across all Locations is greater than zero. Archival is the end of sell-through, not the start of it","when":"archive","field":"Status","severity":"error"},{"rule":"An Archived Item can neither be sold nor ordered. This is strictly stronger than isDiscontinued, which stops ordering only — the normal path is discontinue, sell through to zero, then archive","when":"always","field":"Status","severity":"error"},{"rule":"location must be unique within locationActivity — at most one activity entry per Location","when":"always","field":"locationActivity","severity":"error"},{"rule":"vendor must be unique within vendorActivity — at most one activity entry per Vendor. Entries are never deleted on vendor unassignment; vendorActivity[].vendor need not be present in vendors[]","when":"always","field":"vendorActivity","severity":"error"}],"lifecycle":{"states":["Draft","Active","Archived"],"disallowed":[{"to":"Draft","from":"Active","reason":"Items cannot revert to Draft once activated"},{"to":"Draft","from":"Archived","reason":"Items cannot revert to Draft once activated"}],"transitions":[{"to":"Active","from":"Draft","conditions":["Parent Product is Active","SKU is assigned"]},{"to":"Archived","from":"Active","conditions":["Total qty on hand across all Locations is <= 0","No open order lines reference this Item"]},{"to":"Active","from":"Archived","conditions":["Parent Product is Active"]}],"initialState":"Draft","terminalExits":["Draft"]},"calculations":[{"info":"Concatenation of the currently set AttributeValue names separated by a space (e.g. 'Red Large Cotton'). Recalculated when attributeValues change.","name":"itemName","formula":"CONCAT(attributeValues[].name, ' ')","trigger":"on-write"},{"name":"vendorCount","formula":"COUNT(DISTINCT vendor via vendors)","trigger":"query-time"},{"name":"salesChannelCount","formula":"COUNT(DISTINCT salesChannel via Product.salesChannels)","trigger":"query-time"},{"name":"priceCount","formula":"COUNT(prices)","trigger":"query-time"},{"info":"Per-vendor. Unit cost from the most recent Receipt entry in the Stock Ledger Service for this Item × Vendor. Event-sourced cached read; Stock Ledger is the source of truth.","name":"VendorItemValue.lastReceivedCost","formula":"read-through from Stock Ledger Service: FIRST(LedgerEntry.unitCost ORDER BY transactionDate DESC WHERE item = self.item AND vendor = self.vendor AND txType = 'Receipt')","trigger":"event: LedgerEntryCreated(txType=Receipt) from Stock Ledger Service"},{"info":"Per-vendor. Unit cost from the most recent Purchase Order line for this Item × Vendor. Renamed from lastOrderedCost on 2026-07-24.","name":"VendorItemValue.lastPurchasedCost","formula":"FIRST(PurchaseOrderLine.unitCost ORDER BY PurchaseOrderLine.orderDate DESC WHERE PurchaseOrderLine.item = self.item AND PurchaseOrderLine.vendor = self.vendor)","trigger":"on-po-line-write (CONNECT PO module)"},{"info":"Item-level roll-up across all vendors. Event-sourced cached read.","name":"lastReceivedCost","formula":"read-through from Stock Ledger Service: FIRST(LedgerEntry.unitCost ORDER BY transactionDate DESC WHERE item = self AND txType = 'Receipt')","trigger":"event: LedgerEntryCreated(txType=Receipt) from Stock Ledger Service"},{"info":"Item-level roll-up across all vendors. Computed from CONNECT PO module (POs are not in Stock Ledger Service v1 scope).","name":"lastPurchasedCost","formula":"FIRST(PurchaseOrderLine.unitCost ORDER BY PurchaseOrderLine.orderDate DESC WHERE PurchaseOrderLine.item = self)","trigger":"on-po-line-write (CONNECT PO module)"},{"info":"Method-agnostic per-unit inventory value for this Item, rolled up across all Item Stock rows (locations), weighted by on-hand qty. Null if no Item Stock rows have positive on-hand qty.","name":"currentUnitCost","formula":"SUM(ItemStock.unitCost * ItemStock.onHand) / SUM(ItemStock.onHand) WHERE ItemStock.item = self AND ItemStock.onHand > 0","trigger":"event: InventoryPositionChanged from Stock Ledger Service (propagated via Item Stock)"},{"info":"Per-location activity dates maintained directly from upstream events, in parallel with Item Stock — not read from it, so the product/item domain stays independent of the inventory projection.","name":"locationActivity","formula":"per Location: MIN/MAX of qualifying event timestamps for this Item at that Location (received = movementType Receipt/Return; sold = Sale; transferred = Transfer; purchased = PurchaseOrderLine.orderedAt WHERE deliverTo = location); lastActivityAt = MAX(lastPurchasedAt, lastReceivedAt, lastSoldAt)","trigger":"event: InventoryPositionChanged from Stock Ledger; PurchaseOrderLineCreated/Updated from CONNECT PO module"},{"info":"Per-vendor activity dates (purchase/receipt legs only). Entries created lazily, NEVER deleted on vendor unassignment — vendors[] stays pure sourcing config while history survives vendor changes.","name":"vendorActivity","formula":"per Vendor: firstPurchasedAt/lastPurchasedAt = MIN/MAX(PurchaseOrderLine.orderedAt WHERE vendor); firstReceivedAt/lastReceivedAt = MIN/MAX of Receipt/Return events sourced from that vendor","trigger":"event: PurchaseOrderLineCreated/Updated from CONNECT PO module; goods-receipt/ledger events for received"}],"crossEntityConstraints":[{"rule":"Must belong to a valid Product","when":"always","entity":"Product"},{"rule":"Barcode resolution is Company-wide, so it spans every Item under every Product — not just siblings within one Product","when":"always","entity":"Product"},{"rule":"Every vendors[].vendor must exist in the parent Product's vendors[]. Item.vendors adds per-variant commercial values to an existing sourcing relationship; it never establishes a different vendor set. Unlinking a Vendor at Product level must cascade to remove or invalidate the corresponding Item entries","when":"always","entity":"Product"},{"rule":"The preferred vendor for a Franchise Group resolves from Product.preferredVendors, never from the Item. If that Vendor has no vendors[] entry it inherits Product cost; if its entry is isDiscontinued, sourcing falls back to the Product's isPrimary vendor and the gap is surfaced rather than silently substituted","when":"always","entity":"Product"},{"rule":"Scanning a VendorItemBarcode at receiving posts packQty units of this Item, not one. A Goods Receipt line recorded from a Case or Inner scan must multiply by packQty before writing the received quantity","when":"always","entity":"Goods Receipt"},{"rule":"Cannot archive Item while qty on hand is greater than zero at any Location — an Item must sell through to zero before it can be archived","when":"archive","entity":"Item Stock"},{"rule":"isDiscontinued = true must not remove the Item from the Sales Channels it is published to. A discontinued Item remains sellable through remaining stock; only Archived stops the sale","when":"always","entity":"Sales Channel"},{"rule":"isDiscontinued = true blocks new Purchase Order lines for this Item from every Vendor. VendorItemValue.isDiscontinued blocks them for one Vendor only","when":"always","entity":"Purchase Order"},{"rule":"vendors[].vendor must resolve to an Active Vendor within the Company","when":"always","entity":"Vendor"},{"rule":"locationActivity[].location must reference a valid Location","when":"always","entity":"Location"},{"rule":"locationActivity entries must reconcile with the corresponding Item Stock rows' same-named date fields","when":"always","entity":"Item Stock"},{"rule":"locationActivity entries aggregate up to Product.locationActivity[location] across the Product's child Items (MIN for first*, MAX for last*)","when":"always","entity":"Product"},{"rule":"vendorActivity[].vendor must reference a valid Vendor (need not be currently assigned in vendors[] — entries survive unassignment)","when":"always","entity":"Vendor"},{"rule":"vendorActivity entries aggregate up to Product.vendorActivity[vendor] across the Product's child Items (MIN for first*, MAX for last*)","when":"always","entity":"Product"}]},"inlineSchemas":[{"name":"ItemMedia","schema":"schemas/item/ItemMedia.ts","properties":[{"n":"images","t":"array","re":"Media","info":"Item image assets."},{"n":"thumbnails","t":"array","re":"Media","info":"Thumbnail image assets."},{"n":"videos","t":"array","re":"Media","info":"Video assets."}]},{"name":"ItemPrice","schema":"schemas/item/ItemPrice.ts","properties":[{"name":"price","type":"decimal","required":true},{"name":"priceLevel","type":"entityDetail","required":true,"relatedEntity":"Price Level"}]},{"name":"ItemStatus","schema":"schemas/item/ItemStatus.ts","properties":[{"info":"Current lifecycle state of the item.","name":"status","type":"enumeration","values":["Draft","Active","Archived"],"required":true},{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"}]},{"name":"VendorCost","info":"Shared with Product — see Product.inlineSchemas.VendorCost","schema":"schemas/product-common/VendorCost.ts"},{"name":"ItemBarcode","info":"A scannable code the RETAILER owns for this Item — the code on the price ticket, shelf label or product itself, scanned at the POS. Always identifies one selling unit; there is no pack dimension, because a retailer barcode that meant 'a case of six' would ring up wrong. Split from the vendor-side schema on 31 Aug 2026: the two were previously one shape used in two places, which forced a single set of validation rules onto codes with genuinely different jobs. See VendorItemBarcode for the receiving-side counterpart.","schema":"schemas/item/ItemBarcode.ts","properties":[{"info":"GTIN-8, GTIN-12, GTIN-13 or GTIN-14 value. Must resolve to exactly one Item across the Company — see Item business validation for the full rule, which spans vendor barcodes too.","name":"barcode","type":"string","required":true},{"info":"Whether this is the retailer's primary code for the Item — the one printed on tickets and labels. At most one primary per Item.","name":"isPrimary","type":"boolean","required":true}]},{"name":"OptionValue","desc":"A single selectable value within an Option's value set, as assigned to an Item. Extends LookupEntityValue — inherits id, identifiers, code, name, aliases, isActive, isDeleted, isDefault, sequence, audit fields, and customData.","schema":"schemas/product-common/OptionValue.ts","extends":"LookupEntityValue","properties":[{"info":"Reference to the parent Option dictionary entity this value belongs to.","name":"option","type":"entityDetail","required":true,"relatedEntity":"Option"},{"info":"A +/- difference the option value applies to the line-level price. This is a relative modifier, not a transactional adjustment — does not write to the Price Ledger.","name":"priceModifier","type":"decimal"},{"name":"vendorCostModifiers","type":"array<VendorOptionCostModifier>"}]},{"name":"AttributeValue","schema":"schemas/product-common/AttributeValue.ts","properties":[{"info":"Reference to the parent Attribute dictionary entity this value belongs to.","name":"attribute","type":"entityDetail","required":true,"relatedEntity":"Attribute"},{"name":"code","type":"string","required":true},{"name":"name","type":"string"},{"info":"Ordinal position of this value within the parent Attribute (e.g. 1 = first colour, 2 = second colour).","name":"sequence","type":"integer"},{"name":"aliases","type":"array<string>"}]},{"name":"VendorItemValue","info":"Per-variant commercial values for one Vendor already linked to the parent Product. Item.vendors is a SUBSET of Product.vendors — the two lists must not diverge, and an entry here can never introduce a Vendor the Product is not sourced from. The sourcing relationship (who we buy from, who is primary, which vendor each Franchise Group prefers) lives entirely on Product.vendors; this schema carries only what genuinely varies per SKU: the vendor's own SKU and barcodes, cost, pack size, unit of measure, dropship eligibility, and per-vendor discontinuation. ABSENCE MEANS INHERIT: an Item with no entry for a Vendor the Product carries simply has no per-variant overrides and resolves to the Product-level cost — absence is never a statement that the vendor does not supply the item. To say that, create an entry with isDiscontinued = true. isPrimary was removed on 31 Aug 2026: primary vendor is a relationship fact and is always the Product's, never restated per variant.","schema":"schemas/item/VendorItemValue.ts","properties":[{"info":"Reference to the Vendor. Unique within vendors. MUST be present in Product.vendors[].vendor.","name":"vendor","type":"entityDetail","required":true,"relatedEntity":"Vendor"},{"info":"The vendor's scannable codes for this variant, read at receiving. Uses VendorItemBarcode, NOT the retailer-side ItemBarcode — split on 31 Aug 2026 because vendor codes carry a pack dimension (a case code resolves to N units) and need not be GTINs. Empty array default.","name":"barcodes","type":"array<VendorItemBarcode>","required":true},{"info":"[RESTRICTED:cost] Per-variant base cost from this vendor, before cost-level overrides. Overrides ProductVendor.defaultBaseCost: effectiveValue = VendorItemValue.baseCost ?? ProductVendor.defaultBaseCost. Naming follows child-override-matches-parent (default{Name} -> {name}).\n\nThat parent property did not exist until 01 Sep 2026 — this field claimed an override against nothing, so an Item with no baseCost fell back to nothing rather than to a Product baseline. It could not have been folded into ProductVendor.costs either, because VendorCost requires costLevel and no entry can represent a cost that precedes cost-level assignment. ProductVendor.defaultBaseCost was added to close the gap.\n\nThis is the VENDOR's base cost for this variant, per vendor entry — a retailer has one price and many costs. Hidden from holders of portal:product:read unless they also carry portal:product:cost:read; mutated only via the portal:product:change-cost operation, which posts to Cost Ledger. See convention restricted-field-marker.","name":"baseCost","type":"decimal"},{"info":"Whether this vendor will dropship this specific variant. Per item — a vendor may dropship small variants and not bulky ones.","name":"canDropship","type":"boolean","required":true},{"info":"[RESTRICTED:cost] Per Cost Level cost for this variant from this vendor. Overrides ProductVendor.costs. Element shape is the shared VendorCost schema, whose cost property carries its own marker. Hidden from holders of portal:product:read unless they also carry portal:product:cost:read; mutated only via the portal:product:change-cost operation, which posts to Cost Ledger. See convention restricted-field-marker.","name":"costs","type":"array<VendorCost>","required":true},{"info":"When this Vendor stopped supplying this variant. Null when isDiscontinued is false. UTC.","name":"discontinuedDate","type":"datetime"},{"info":"Whether this VENDOR has stopped supplying this variant. Distinct from Item.isDiscontinued, which is the retailer's decision to stop purchasing entirely — a vendor dropping a line does not make the item unorderable while another vendor still supplies it. A discontinued entry is excluded from sourcing resolution but is never deleted; historical costs stay readable. Added 31 Aug 2026. Per boolean-must-be-required.","name":"isDiscontinued","type":"boolean","required":true},{"info":"[RESTRICTED:cost] Calculated field. Unit cost from the most recent Purchase Order line for this Item from this specific Vendor. Named Purchased (not Ordered) to avoid confusion with sales/customer orders. Null if never purchased from this vendor. Item.lastPurchasedCost is the roll-up across all vendors. Renamed from lastOrderedCost on 2026-07-24. Marked because it EXPOSES cost regardless of being derived rather than authored — leaving it open would hide the cost the user sets and display the cost the system computed. Hidden from holders of portal:product:read unless they also carry portal:product:cost:read. See convention restricted-field-marker.","name":"lastPurchasedCost","type":"decimal","calculated":true},{"info":"[RESTRICTED:cost] Calculated field. Unit cost from the most recent received inventory transaction for this Item from this specific Vendor. Sourced from the Stock Ledger (movementType Receipt or Return) — the Cost Ledger records agreed vendor cost, not received cost, and is NOT a source for this field (corrected 2026-08-31). Vendor attribution resolves through the receipt's sourceEntityId → Goods Receipt → Purchase Order; Item.vendorActivity[] holds the corresponding Item × Vendor activity dates. Null if never received from this vendor. Item.lastReceivedCost is the roll-up across all vendors. Slated for migration to vendorActivity[].lastReceivedAmount per registry question #11 — the marker must travel with it. Marked because it exposes cost regardless of being derived. Hidden from holders of portal:product:read unless they also carry portal:product:cost:read. See convention restricted-field-marker.","name":"lastReceivedCost","type":"decimal","calculated":true},{"info":"Minimum order quantity for this variant from this vendor. Varies per SKU where pack sizes differ by size or colour.","name":"minOrderQty","type":"decimal"},{"info":"The vendor's own SKU for this variant. Not unique and not ours — distinct from Item.sku.","name":"sku","type":"string"},{"info":"Whether this vendor sells this variant by case or by unit. Governs how the line is ordered; the per-barcode packLevel/packQty on VendorItemBarcode governs how a scan is interpreted at receiving. The two must be consistent.","name":"unitOfMeasure","type":"enumeration","values":["Case","Unit"],"required":true},{"info":"Quantity per unit of measure (e.g. units per case) for ordering. Where a vendor publishes several codes at different pack levels, the per-barcode packQty is the authoritative figure for a receiving scan.","name":"unitOfMeasureQty","type":"decimal"}]},{"name":"ItemMeasurements","schema":"schemas/item/ItemMeasurements.ts","properties":[{"name":"height","type":"string"},{"name":"length","type":"string"},{"name":"weight","type":"string"},{"name":"width","type":"string"}]},{"name":"VendorItemBarcode","info":"A scannable code a VENDOR uses for this Item, read at the receiving desk rather than the POS. Differs from ItemBarcode in two ways that make a shared schema wrong. First, it need not be a GTIN — vendors legitimately print proprietary codes in Code 128 or their own SKU symbology — so GTIN validation is a warning here rather than an error. Second and more importantly it CARRIES A PACK DIMENSION: a vendor's case code identifies N units of the Item, not one, so scanning it at receiving must resolve to a quantity. Created 31 Aug 2026 when ItemBarcode was split. packQty is deliberately per-barcode rather than read from VendorItemValue.unitOfMeasureQty, because one vendor commonly publishes an each code, an inner-pack code and a case code for the same Item, and a single unitOfMeasureQty cannot describe all three.","schema":"schemas/item/VendorItemBarcode.ts","properties":[{"info":"The vendor's scannable code. GTIN format is expected but not required. Must resolve to exactly one Item across the Company — see Item business validation.","name":"barcode","type":"string","required":true},{"info":"Whether this is the code the vendor treats as primary for this Item. Scoped to this vendor's list — a different designation from ItemBarcode.isPrimary, and the two need not agree. At most one primary per vendor list.","name":"isPrimary","type":"boolean","required":true},{"info":"What quantity of the Item one scan of this code represents. Each = a single selling unit; Inner = an intermediate pack; Case = a full shipping carton. Determines how a receiving scan converts to units.","name":"packLevel","type":"enumeration","values":["Each","Inner","Case"],"required":true},{"info":"Units of the Item represented by one scan of this code. Must be 1 when packLevel = 'Each', and greater than 1 otherwise. This is what makes case receiving work: scanning a case code posts packQty units, not one.","name":"packQty","type":"decimal","required":true}]},{"name":"ItemVendorActivity","info":"Per-vendor lifecycle/activity dates for this Item (Item × Vendor grain). Activity projection keyed by vendor — deliberately separate from vendors[] (VendorItemValue), which is current sourcing config: entries here are NEVER deleted when a vendor is unassigned, so historical activity survives vendor changes (supports 'when/what did we last purchase from vendor X' after switching vendors). Entries created lazily on first activity with a vendor; vendor unique within the array. Only the purchase/receipt legs exist — sales and transfers have no vendor dimension. Maintained from PO line events (purchased) and goods-receipt/ledger events (received). Amount fields (lastPurchasedAmount, lastReceivedAmount) are scoped to be added by the deferred amounts request — see registry question #11.","schema":"schemas/item/ItemVendorActivity.ts","extends":"OperationalSubDocument","properties":[{"info":"The Vendor this activity entry applies to. Unique within vendorActivity. Need NOT be present in vendors[] — entries survive vendor unassignment.","name":"vendor","type":"entityDetail","required":true,"relatedEntity":"Vendor"},{"info":"Earliest PO line placed for this Item with this Vendor. Named Purchased (not Ordered) to avoid confusion with sales/customer orders.","name":"firstPurchasedAt","type":"datetime","calculated":true},{"info":"Most recent PO line placed for this Item with this Vendor.","name":"lastPurchasedAt","type":"datetime","calculated":true},{"info":"Earliest external receipt (movementType Receipt or Return) of this Item sourced from this Vendor.","name":"firstReceivedAt","type":"datetime","calculated":true},{"info":"Most recent external receipt (movementType Receipt or Return) of this Item sourced from this Vendor.","name":"lastReceivedAt","type":"datetime","calculated":true}]},{"name":"StockLimitItemValue","schema":"schemas/item/StockLimitItemValue.ts","properties":[{"name":"maxStockLimit","type":"decimal"},{"name":"minStockLimit","type":"decimal"},{"name":"stockLimitGroup","type":"entityDetail","required":true,"relatedEntity":"Stock Limit Group"},{"name":"stockLimitLocation","type":"entityDetail","required":true,"relatedEntity":"Location"},{"info":"Time period for stock limit evaluation (e.g. Weekly, Monthly). Not a persisted entity — a code + name label pair representing the replenishment cycle.","name":"stockLimitPeriod","type":"nonIdentifiableEntityDetail","required":true}]},{"name":"ItemLocationActivity","info":"Per-location lifecycle/activity dates for this Item (Item × Location grain). Mirrors ProductLocationActivity so dates are available consistently on Product and child Items without depending on Item Stock — Item Stock may be used independently of the product/item entities (e.g. synced from an external IMS). Maintained directly from the same upstream events (InventoryPositionChanged from the Stock Ledger; PO line events from CONNECT), in parallel with Item Stock; Item Stock rows reconcile against these entries as a consistency check. Entries created lazily on first activity at a Location; location unique within the array. Movement categorization per the Stock Ledger movementType enum (received = Receipt/Return; sold = Sale; transferred = Transfer, direction via qty sign; Adjustments don't count as activity).","schema":"schemas/item/ItemLocationActivity.ts","extends":"OperationalSubDocument","properties":[{"info":"The Location this activity entry applies to. Unique within locationActivity.","name":"location","type":"entityDetail","required":true,"relatedEntity":"Location"},{"info":"Earliest PO line placed for this Item with deliverTo = this location. Named Purchased (not Ordered) to avoid confusion with sales/customer orders.","name":"firstPurchasedAt","type":"datetime","calculated":true},{"info":"Most recent PO line placed for this Item with deliverTo = this location.","name":"lastPurchasedAt","type":"datetime","calculated":true},{"info":"Earliest external receipt (movementType Receipt or Return — transfers post as Transfer, tracked separately) of this Item at this Location.","name":"firstReceivedAt","type":"datetime","calculated":true},{"info":"Most recent external receipt (movementType Receipt or Return) of this Item at this Location.","name":"lastReceivedAt","type":"datetime","calculated":true},{"info":"Earliest sale (movementType Sale — Sales Channel is an attribute of the sale, not a movement type) of this Item at this Location.","name":"firstSoldAt","type":"datetime","calculated":true},{"info":"Most recent sale (movementType Sale) of this Item at this Location.","name":"lastSoldAt","type":"datetime","calculated":true},{"info":"Earliest Transfer movement (paired entries; direction via qty sign; both directions count) of this Item at this Location.","name":"firstTransferredAt","type":"datetime","calculated":true},{"info":"Most recent Transfer movement of this Item at this Location. Kept separate so transfer activity is composable in or out of recency.","name":"lastTransferredAt","type":"datetime","calculated":true},{"info":"Most recent activity for this Item at this Location, transfers excluded: MAX(lastPurchasedAt, lastReceivedAt, lastSoldAt). Transfer-inclusive recency = MAX(lastActivityAt, lastTransferredAt) at query time.","name":"lastActivityAt","type":"datetime","calculated":true}]},{"name":"SalesChannelItemValue","schema":"schemas/item/SalesChannelItemValue.ts","properties":[{"name":"isPublished","type":"boolean","required":true},{"name":"salesChannel","type":"entityDetail","required":true,"relatedEntity":"Sales Channel"}]},{"name":"VendorOptionCostModifier","schema":"schemas/product-common/VendorOptionCostModifier.ts","properties":[{"info":"[RESTRICTED:cost] A +/- difference the option value applies to the vendor cost. This is a relative modifier, not a transactional adjustment — does not write to the Cost Ledger. Marked because a modifier still reveals cost information: exposed alongside a visible base it gives the derived figure directly, and on its own it discloses the vendor's cost structure. Hidden from holders of portal:product:read unless they also carry portal:product:cost:read. See convention restricted-field-marker.","name":"costModifier","type":"decimal","required":true},{"name":"vendor","type":"entityDetail","required":true,"relatedEntity":"Vendor"}]}],"inheritance":{"parentEntity":"Product","overridableProperties":["basePrice","dropshipEligibility","measurements","prices","weeksOfSupply"]}},{"name":"Item Stock","class":"Operational","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"Current-state inventory projection per Item × Location. A mutable, continuously-updated snapshot of stock quantities (on_hand, committed, incoming, reserved, safety_stock, damaged, QC) derived from Stock Ledger entries or synced from an external inventory management system. Not an immutable record — recalculated as movements flow in.","status":"reviewed","properties":[{"c":true,"n":"baseTotalCost","t":"decimal","info":"totalCost converted to the Company's base/reporting currency. Null if currencyCode already equals the Company's base currency. Used for cross-location consolidated valuation reports. Cached read from the Stock Ledger Service."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. Always matches the company of the Item and Location it projects."},{"n":"costingMethod","r":true,"t":"enumeration","v":["FIFO","LIFO","WAC"],"info":"Effective inventory costing method for this Item × Location. On creation, inherits from Company.defaultCostingMethod; may be overridden per the Stock Ledger Service PRD. Changing this value after posting entries requires an offline replay job — it is not a hot-path edit."},{"n":"currencyCode","r":true,"t":"string","info":"ISO 4217 currency code for unitCost and totalCost. Typically matches the Company's baseCurrencyCode but may differ for locations operating in a different currency. Mirrors Stock Ledger InventoryPosition.currencyCode."},{"c":true,"n":"firstPurchasedAt","t":"datetime","info":"Earliest timestamp a purchase order line was placed for this Item × Location. Named Purchased (not Ordered) to avoid confusion with sales/customer orders — this is strictly PO activity. Derived from PurchaseOrderLine — PO is upstream of the Stock Ledger, so this is aggregated in CONNECT rather than read-through from the Stock Ledger Service. Null until the first PO line is created. Item-level firstPurchasedAt = MIN across Item Stock rows for the Item. Renamed from firstOrderedAt on 2026-07-24."},{"c":true,"n":"firstReceivedAt","t":"datetime","info":"Earliest timestamp this Item was received at this Location from an external source. Read-through projection from the Stock Ledger, movementType IN ('Receipt','Return') — goods receipts and returns-to-stock. Transfers never qualify: transfer movements post as movementType 'Transfer' (paired entries, direction via qty sign) and are tracked separately via firstTransferredAt so transfer activity can be included or excluded from recency analysis. Null until the first external receipt posts. Item-level firstReceivedAt = MIN across Item Stock rows for the Item."},{"c":true,"n":"firstSoldAt","t":"datetime","info":"Earliest timestamp this Item was sold at this Location. Read-through projection from Stock Ledger Service (movementType 'Sale' — a sale is a sale regardless of channel; the Sales Channel is an attribute of the sale transaction, not a distinct movement type). Null until the first sale posts. Introduction-date / sell-through analytics. Item-level firstSoldAt = MIN across Item Stock rows for the Item."},{"c":true,"n":"firstTransferredAt","t":"datetime","info":"Earliest timestamp this Item was transferred in or out of this Location. Read-through projection from the Stock Ledger, movementType 'Transfer' — transfers post paired entries (negative at source, positive at destination), so direction is the qty sign; both directions count as transfer activity. Kept separate from firstReceivedAt so transfer activity is composable in or out of recency analysis. Null if never transferred. Item-level firstTransferredAt = MIN across Item Stock rows for the Item."},{"n":"itemNo","r":true,"t":"integer"},{"c":true,"n":"lastActivityAt","t":"datetime","info":"Most recent activity timestamp for this Item × Location, transfers excluded: MAX(lastPurchasedAt, lastReceivedAt, lastSoldAt). Primary indexed key for recency filtering (recent-activity checks against a configurable window, e.g. Company.activityRecencyDays). For transfer-inclusive recency, evaluate MAX(lastActivityAt, lastTransferredAt) at query time — no separate stored field needed. Maintained on the same events that update its constituent fields."},{"c":true,"n":"lastPurchasedAt","t":"datetime","info":"Most recent timestamp a purchase order line was placed for this Item × Location. Named Purchased (not Ordered) to avoid confusion with sales/customer orders — this is strictly PO activity. Derived from PurchaseOrderLine (PO upstream of Ledger; aggregated in CONNECT, not read-through from Stock Ledger Service). Used for replenishment recency and open-to-buy. Item-level lastPurchasedAt = MAX across Item Stock rows for the Item. Renamed from lastOrderedAt on 2026-07-24."},{"c":true,"n":"lastReceivedAt","t":"datetime","info":"Most recent timestamp this Item was received at this Location from an external source. Read-through projection from the Stock Ledger, movementType IN ('Receipt','Return') — goods receipts and returns-to-stock. Transfers never qualify: transfer movements post as movementType 'Transfer' and are tracked separately via lastTransferredAt so transfer activity can be included or excluded from recency analysis. Drives replenishment cadence and aged-inbound reporting. Item-level lastReceivedAt = MAX across Item Stock rows for the Item."},{"c":true,"n":"lastSoldAt","t":"datetime","info":"Most recent timestamp this Item was sold at this Location. Read-through projection from Stock Ledger Service (movementType 'Sale' — a sale is a sale regardless of channel; the Sales Channel is an attribute of the sale transaction, not a distinct movement type). Primary signal for slow-mover / aged-stock reporting. Item-level lastSoldAt = MAX across Item Stock rows for the Item."},{"c":true,"n":"lastTransferredAt","t":"datetime","info":"Most recent timestamp this Item was transferred in or out of this Location. Read-through projection from the Stock Ledger, movementType 'Transfer' — transfers post paired entries (negative at source, positive at destination), so direction is the qty sign; both directions count as transfer activity. Kept separate from lastReceivedAt so transfer activity is composable in or out of recency analysis (transfer-inclusive recency = MAX(lastActivityAt, lastTransferredAt) at query time). Item-level lastTransferredAt = MAX across Item Stock rows for the Item."},{"n":"location","r":true,"t":"entityDetail","re":"Location"},{"n":"locationQtys","r":true,"t":"schema","info":"Map of location IDs to stock quantity breakdowns. Each entry contains on-hand, committed, incoming, reserved, and other quantity dimensions."},{"n":"productCode","r":true,"t":"string","info":"Product code for the item."},{"n":"stockBin","t":"entityRef","re":"StockBin"},{"c":true,"n":"totalCost","t":"decimal","info":"Total cost of on-hand inventory for this Item × Location under the configured costingMethod. Sum across remaining lots under FIFO/LIFO; qtyOnHand × running-average under WAC. Cached read from the Stock Ledger Service's InventoryPosition. Basis for inventory valuation reports."},{"c":true,"n":"unitCost","t":"decimal","info":"Current per-unit inventory cost under the configured costingMethod. FIFO = unit cost of the oldest remaining lot; LIFO = unit cost of the newest remaining lot; WAC = totalCost / qtyOnHand. Cached read from the Stock Ledger Service's InventoryPosition; the Stock Ledger is the source of truth. Updated via InventoryPositionChanged events."}],"shopify":"InventoryLevel resource","notes":"Sourcing for the computed datetime fields on Item Stock splits across two upstreams. Received/Sold/Transferred pairs are read-through projections from the Stock Ledger, updated via InventoryPositionChanged — consistent with the existing cost-field pattern. Movement categorization uses the Stock Ledger movementType enum (Sale, Receipt, Adjustment, Transfer, Return): received = Receipt/Return; sold = Sale (a sale is a sale regardless of channel — Sales Channel is an attribute of the sale transaction, not a movement type; the spurious 'EcomFulfilment' value was removed 2026-07-24); transferred = Transfer (paired entries, direction via qty sign — both directions count). Adjustments do not count as activity. The Purchased pair is sourced from PurchaseOrderLine in CONNECT, NOT the ledger: the ledger sees receipts, not PO creation, and expanding the Stock Ledger Service to consume PO events would breach its movement-of-inventory scope boundary from PRD v1.0. All first*/last* fields aggregate up to the identically-named Item-level fields via MIN (first*) / MAX (last*) per the product-read-through-must-aggregate-item convention, and roll up per-location to Product.locationActivity (Product × Location grain). first* are write-once; last* = MAX(stored, incoming) to tolerate out-of-order postings; reversals/voids do not bump recency. lastActivityAt (MAX of lastPurchasedAt/lastReceivedAt/lastSoldAt, transfers excluded) is the indexed recency key; transfer-inclusive recency = MAX(lastActivityAt, lastTransferredAt) at query time. Naming history: ...Date → ...At on 2026-04-21; first/lastOrderedAt → first/lastPurchasedAt on 2026-07-24 to avoid confusion with sales/customer orders; formulas aligned to the coarse Stock Ledger movementType enum on 2026-07-24 (earlier drafts used granular values POReceipt/ReturnToStock/TransferIn/TransferOut that do not exist in the enum).","related":["Item","Location","StockBin","Stock Ledger"],"bv":{"rules":[{"rule":"SOH (Stock on Hand) must not go negative unless Company settings allow negative inventory","when":"always","field":"Quantity","severity":"error"}],"lifecycle":null,"calculations":[{"name":"Available","formula":"SOH - Allocated - Reserved","trigger":"On stock movement or allocation change"},{"name":"SOH","formula":"Sum of Stock Ledger entries for Item + Location","trigger":"On any stock movement"},{"info":"Per-unit inventory cost for this Item × Location under the configured costingMethod. Cached read from the Stock Ledger Service's InventoryPosition.unitCost — not natively computed by CONNECT. Method dispatch happens inside the Stock Ledger Service: FIFO=unit cost of oldest remaining LedgerCost layer, LIFO=newest remaining layer, WAC=totalCost/qtyOnHand running average. Updated whenever the Stock Ledger publishes an InventoryPositionChanged or CostRecalculated event for this Item × Location.","name":"unitCost","formula":"read-through from Stock Ledger Service: InventoryPosition.unitCost WHERE item = self.item AND location = self.location (method-dispatched inside the service)","trigger":"event: InventoryPositionChanged or CostRecalculated from Stock Ledger Service"},{"info":"Total cost of on-hand inventory for this Item × Location under the configured costingMethod. Cached read from the Stock Ledger Service's InventoryPosition.totalCost. FIFO/LIFO: sum across remaining LedgerCost layers (qty × unitCost per layer). WAC: qtyOnHand × running-average unit cost. Source of truth is the Stock Ledger Service.","name":"totalCost","formula":"read-through from Stock Ledger Service: InventoryPosition.totalCost WHERE item = self.item AND location = self.location","trigger":"event: InventoryPositionChanged or CostRecalculated from Stock Ledger Service"},{"info":"totalCost converted to the Company's base/reporting currency for cross-location consolidated valuation. Cached read from the Stock Ledger Service's InventoryPosition.baseTotalCost. Null when currencyCode already equals Company.baseCurrencyCode. The Stock Ledger Service performs the FX conversion using the rate captured at the time of each LedgerEntry.","name":"baseTotalCost","formula":"read-through from Stock Ledger Service: InventoryPosition.baseTotalCost WHERE item = self.item AND location = self.location (null if currency = base)","trigger":"event: InventoryPositionChanged or CostRecalculated from Stock Ledger Service"},{"info":"Earliest timestamp this Item was received at this Location from an external source — movementType Receipt (goods receipts) or Return (returns-to-stock). Transfers never qualify (they post as movementType Transfer, tracked via firstTransferredAt). Write-once semantics per Item × Location. Aggregates up to Item.firstReceivedAt via MIN per the product-read-through-must-aggregate-item convention.","name":"firstReceivedAt","formula":"MIN(LedgerEntry.postedAt) WHERE item = self.item AND location = self.location AND movementType IN ('Receipt','Return')","trigger":"event: InventoryPositionChanged from Stock Ledger (first external receipt)"},{"info":"Most recent timestamp this Item was received at this Location from an external source — movementType Receipt or Return. Transfers never qualify (movementType Transfer, tracked via lastTransferredAt). Drives replenishment cadence and aged-inbound reporting. Aggregates up to Item.lastReceivedAt via MAX.","name":"lastReceivedAt","formula":"MAX(LedgerEntry.postedAt) WHERE item = self.item AND location = self.location AND movementType IN ('Receipt','Return')","trigger":"event: InventoryPositionChanged from Stock Ledger on any Receipt/Return movement"},{"info":"Earliest timestamp a purchase order line was placed for this Item × Location. Named Purchased (not Ordered) to avoid confusion with sales/customer orders. Sourced from PurchaseOrderLine.orderedAt — NOT a read-through from the Stock Ledger. The ledger sees receipts, not PO creation; POs are upstream. CONNECT owns this aggregation. Aggregates up to Item.firstPurchasedAt via MIN.","name":"firstPurchasedAt","formula":"MIN(PurchaseOrderLine.orderedAt) WHERE item = self.item AND deliverTo = self.location","trigger":"event: PurchaseOrderLineCreated (write-once semantics per Item × Location)"},{"info":"Most recent timestamp a purchase order line was placed for this Item × Location. Named Purchased (not Ordered) to avoid confusion with sales/customer orders. Sourced from PurchaseOrderLine.orderedAt (not Stock Ledger). Used for replenishment recency, open-to-buy, and reorder-gap analysis. Aggregates up to Item.lastPurchasedAt via MAX.","name":"lastPurchasedAt","formula":"MAX(PurchaseOrderLine.orderedAt) WHERE item = self.item AND deliverTo = self.location","trigger":"event: PurchaseOrderLineCreated or PurchaseOrderLineUpdated"},{"info":"Earliest timestamp this Item was sold at this Location — movementType Sale only. A sale is a sale regardless of channel; Sales Channel is an attribute of the sale transaction, not a movement type. Introduction-date / sell-through analytics. Write-once per Item × Location. Aggregates up to Item.firstSoldAt via MIN.","name":"firstSoldAt","formula":"MIN(LedgerEntry.postedAt) WHERE item = self.item AND location = self.location AND movementType = 'Sale'","trigger":"event: InventoryPositionChanged from Stock Ledger (first sale)"},{"info":"Most recent timestamp this Item was sold at this Location — movementType Sale only. A sale is a sale regardless of channel; Sales Channel is an attribute of the sale transaction, not a movement type. Primary signal for slow-mover / aged-stock reporting. Aggregates up to Item.lastSoldAt via MAX.","name":"lastSoldAt","formula":"MAX(LedgerEntry.postedAt) WHERE item = self.item AND location = self.location AND movementType = 'Sale'","trigger":"event: InventoryPositionChanged from Stock Ledger on any Sale movement"},{"info":"Earliest timestamp this Item was transferred in or out of this Location — movementType Transfer. Transfers post paired entries (negative at source, positive at destination); direction is the qty sign and both directions count. Kept separate from firstReceivedAt so transfer activity is composable in or out of recency analysis. Aggregates up to Item.firstTransferredAt via MIN.","name":"firstTransferredAt","formula":"MIN(LedgerEntry.postedAt) WHERE item = self.item AND location = self.location AND movementType = 'Transfer'","trigger":"event: InventoryPositionChanged from Stock Ledger (first Transfer movement)"},{"info":"Most recent timestamp this Item was transferred in or out of this Location — movementType Transfer (paired entries; direction via qty sign; both directions count). Kept separate from lastReceivedAt so transfer activity is composable in or out of recency analysis. Aggregates up to Item.lastTransferredAt via MAX.","name":"lastTransferredAt","formula":"MAX(LedgerEntry.postedAt) WHERE item = self.item AND location = self.location AND movementType = 'Transfer'","trigger":"event: InventoryPositionChanged from Stock Ledger on any Transfer movement"},{"info":"Most recent activity for this Item × Location, transfers excluded. The indexed recency key: recent-activity checks evaluate lastActivityAt >= NOW() - configurable window (e.g. Company.activityRecencyDays). Transfer-inclusive recency = MAX(lastActivityAt, lastTransferredAt) at query time. Aggregates up to Item.lastActivityAt via MAX.","name":"lastActivityAt","formula":"MAX(lastPurchasedAt, lastReceivedAt, lastSoldAt)","trigger":"derived: recomputed whenever lastPurchasedAt, lastReceivedAt, or lastSoldAt changes"}],"crossEntityConstraints":[{"rule":"Must reference a valid Item","entity":"Item"},{"rule":"Must reference a valid Location","entity":"Location"},{"rule":"SOH must reconcile with Stock Ledger running total for the Item/Location","entity":"Stock Ledger"},{"rule":"firstReceivedAt, lastReceivedAt, firstPurchasedAt, lastPurchasedAt, firstSoldAt, lastSoldAt, firstTransferredAt, lastTransferredAt, lastActivityAt must roll up to the parent Item's same-named fields (MIN for first*, MAX for last*) per the product-read-through-must-aggregate-item convention","entity":"Item"},{"rule":"The same fields must roll up per-location to Product.locationActivity[] (aggregated across the Product's Items at each Location)","entity":"Product"},{"rule":"Movement categorization must use the Stock Ledger movementType enum (Sale, Receipt, Adjustment, Transfer, Return); Adjustments do not count as activity","entity":"Stock Ledger"}]},"inlineSchemas":[{"name":"StockQuantities","extends":"OperationalSubDocument","properties":[{"info":"Available to Sell.","name":"ats","type":"integer","required":true},{"info":"Quantity available for backorder.","name":"availableForBackorder","type":"integer","required":true},{"info":"Quantity committed to open orders.","name":"committed","type":"integer","required":true},{"info":"Quantity available for e-commerce channels.","name":"ecommerceAvailable","type":"integer","required":true},{"info":"Quantity on hold.","name":"held","type":"integer","required":true},{"info":"Quantity expected from inbound transfers and purchase orders.","name":"incoming","type":"integer","required":true},{"info":"Last modification timestamp for this location's quantities.","name":"modifiedDate","type":"datetime"},{"info":"Next date when out-of-stock items become available.","name":"nextAvailableDate","type":"datetime"},{"info":"Quantity not available for sale (damaged, QC, etc.).","name":"notSellable","type":"integer","required":true},{"info":"Physical on-hand quantity at the location.","name":"onHand","type":"integer","required":true},{"info":"Quantity reserved for specific purposes.","name":"reserved","type":"integer","required":true}]}]},{"name":"Item Stock Group","class":"Dictionary","subsystem":"CONNECT","area":"Merchandising","desc":"A named group of items that share stocking rules, replenishment strategy and allocation behaviour — the handle a planner uses to apply one policy to many SKUs at once, rather than setting parameters item by item. Merchandising rather than Inventory & Allocation for the same reason as Stock Limit Group: it expresses intent about how a set of items should be replenished, not a fact about stock on hand.","status":"stub","properties":[{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. Replenishment strategy is the tenant's own policy."},{"n":"name","r":true,"t":"string"}],"ext":"LookupEntity","notes":"Filed under Merchandising 03 Sep 2026, moved from Inventory & Allocation. Still a stub: code and name only. The open modelling question is how it relates to Classification and Stock Limit Group — all three group items for planning purposes, and nothing says which one wins when an item's classification default, its stock group and its limit group disagree. Replenishment Plan reports a resolved level on every line, so the precedence has to be stated somewhere before that field means anything.","related":["Item","Item Stock","Classification","Stock Limit Group","Replenishment Plan"]},{"name":"Licensing Tier","class":"Dictionary","subsystem":"PLATFORM","area":"Licensing","desc":"A named, sellable plan level published against ONE licensable blueprint — an Application or a Connector — defining what that product grants at that level. Licensing Tier is the catalogue side of entitlement; the granted side is the LicenseGrant value type carried by Application Installation and Connection, which is where a specific Company's purchase is recorded. Tiers are per-blueprint, not platform-wide: the plan ladder for APR's Shopify App is not the plan ladder for Fulcrum X, and a single global enum of tier names cannot express that. A tier is meaningful only because it grants something, so a tier carries its entitlement as data — includedFeatures names which of the parent blueprint's declared capabilities are switched on, and limits carries the numeric caps — rather than being a bare label that no runtime can enforce. PLATFORM-GLOBAL: this is product catalogue data owned by All Point, not tenant data, so it does NOT declare company and is registered in the platform-global dictionary exemption set alongside Currency and Locale. Ranking between tiers uses the inherited sequence rather than a hardcoded order, so a tier can be inserted into a ladder without a code change. Retiring a tier is a deactivation (isActive false), never a delete, because issued LicenseGrants reference it and historical grants must stay resolvable.","status":"draft","properties":[{"n":"application","t":"entityRef","re":"Application","info":"The Application this tier is a plan level of. Mutually exclusive with connector — exactly one of the two must be set. Null when this tier belongs to a Connector."},{"n":"code","r":true,"t":"string","u":true,"info":"Short, human-readable tier key, unique within the parent blueprint (e.g. 'starter', 'growth', 'professional', 'enterprise'). Redeclared from LookupEntity to tighten the inherited code to required and unique, following the Application.slug precedent for constraint-tightening overrides. Uniqueness is scoped to the owning application/connector rather than globally, so two products may each publish a 'growth' tier. Treat as immutable once any LicenseGrant references this tier."},{"n":"connector","t":"entityRef","re":"Connector","info":"The Connector this tier is a plan level of. Mutually exclusive with application — exactly one of the two must be set. Null when this tier belongs to an Application."},{"n":"includedFeatures","r":true,"t":"array","info":"Feature capability keys this tier switches on. Every value must appear in the parent blueprint's declared capability list (Application.supportedFeatures, or the Connector equivalent) — a tier can only grant capabilities the product actually has. This reuses the existing supportedFeatures contract rather than inventing a second feature vocabulary. Empty array is the minimum default and denotes a tier that grants access but no optional capabilities."},{"n":"limits","r":true,"t":"array","info":"Array of TierLimit inline schema objects carrying the numeric caps this tier imposes (locations, users, monthly sync volume, API calls). Modeled as named key/value/unit rows rather than fixed columns so a new cap can be introduced for one product without altering the schema for all of them. key must be unique within the array. Empty array denotes an uncapped tier."}],"ext":"LookupEntity","notes":"Created 15 Sep 2026 to give licensing a grain. Supersedes a Company.tier enumeration (starter|growth|professional|enterprise) that had been declared on Company but never implemented; it was removed outright on 16 Sep 2026 rather than deprecated, since nothing read or wrote it and there is no data to migrate. That property was wrong on three counts and they are worth recording, because each is a trap this entity is shaped to avoid: it attached licensing to the tenant boundary, when a licence is bought for a specific Application or Connection and one Company routinely runs several at different levels; it was platform-wide, when the plan ladder for APR's Shopify App is not the plan ladder for Fulcrum X; and it was a bare label with no entitlement attached, so no runtime could enforce it. Extends LookupEntity rather than CoreEntity because a tier is reference data with a code/name/sequence shape and needs isActive for retirement; it deliberately does NOT declare the company override, because the tier catalogue is All Point product data rather than tenant data. Both company-scoping-required and tenant-scoped-dictionary-declares-company carry Licensing Tier in their exemption sets. The application/connector pair is modeled as two nullable typed refs with an XOR rule rather than a polymorphic type/id pair (the LedgerEntry sourceEntityType/sourceEntityId precedent) because the set of licensable blueprints is closed and small, and typed refs keep entity-ref-target-exists enforceable. If a third licensable blueprint type appears, revisit and consider the polymorphic shape then.","related":["Application","Connector","Application Installation","Connection","Subscription"],"bv":{"rules":[{"rule":"Exactly one of application or connector must be set. A tier with neither belongs to no product and can never be resolved; a tier with both would claim two plan ladders at once. Enforce as an XOR check on write.","when":"always","field":"application","severity":"error"},{"rule":"Must be unique within the owning blueprint (application or connector), not globally — two different products may each publish a 'growth' tier.","when":"create","field":"code","severity":"error"},{"rule":"Immutable once any LicenseGrant references this tier. Grants embed the tier as entityDetail (id + code + name), so a rename leaves already-issued grants displaying a code that no longer exists in the catalogue.","when":"update","field":"code","severity":"error"},{"rule":"Every value must appear in the parent blueprint's declared capability list — Application.supportedFeatures for an application-owned tier, the Connector equivalent for a connector-owned tier. A tier cannot grant a capability the product does not declare.","when":"always","field":"includedFeatures","severity":"error"},{"rule":"Retire a tier by setting isActive false, never by deleting it. Issued LicenseGrants reference the tier and historical grants must remain resolvable for billing reconciliation and audit. Deactivation blocks new grants only; existing grants continue to resolve until they expire or are migrated.","when":"update","field":"isActive","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"An Application-owned tier's includedFeatures must be a subset of that Application.supportedFeatures. Removing a value from supportedFeatures must therefore either fail or cascade to every tier that grants it.","entity":"Application"},{"rule":"An Application Installation's licensing.tier must be a tier whose application matches that installation's application. A grant pointing at another product's tier is unresolvable.","entity":"Application Installation"},{"rule":"A Connection's licensing.tier must be a tier whose connector matches one of that Connection's connectors. Where source and destination connectors differ, the licensed side is the one declared in the grant.","entity":"Connection"}]},"inlineSchemas":[{"name":"TierLimit","info":"One named numeric cap imposed by a Licensing Tier. Key/value/unit rather than a fixed column per cap, so products can introduce their own limits without a schema change. A null value means the limit is declared but uncapped at this tier, which is deliberately distinct from the key being absent (undeclared, therefore unenforced).","properties":[{"info":"Stable machine key for the cap being limited (e.g. 'locations', 'users', 'monthlySyncRecords', 'apiCallsPerDay'). Unique within the parent tier's limits array. Enforcement code matches on this key, so it is part of the product contract and must not be renamed in place.","name":"key","type":"string","required":true},{"info":"The cap. Null means declared-but-uncapped at this tier, distinct from the key being absent altogether.","name":"value","type":"integer"},{"info":"Unit the value is counted in where the key alone is ambiguous (e.g. 'records/month', 'calls/day'). Presentational; enforcement reads key and value.","name":"unit","type":"string"},{"info":"What happens at the cap. hard = the operation is refused. soft = the operation succeeds and an overage is recorded for billing. advisory = nothing is blocked or billed; the figure drives in-product upgrade prompts only. Declared per limit because the same tier commonly hard-caps seats while soft-capping volume.","name":"enforcement","type":"enumeration","values":["hard","soft","advisory"],"required":true}]}]},{"name":"Locale","class":"Dictionary","subsystem":"ALLPOINT","area":"Organization","desc":"IETF BCP 47 language-region definition. Determines available translations, content localization, and display formatting across the platform. Referenced by Market.localization to define supported languages per market.","status":"draft","properties":[{"n":"language","r":true,"t":"string","info":"ISO 639-1 two-letter language code (e.g. en, fr, es, de, ja, zh)."},{"n":"region","t":"entityDetail","re":"Country","info":"ISO 3166-1 country/region associated with this locale. Null for language-only locales (e.g. 'fr' without a region)."},{"n":"script","t":"string","info":"ISO 15924 script code for languages with multiple writing systems (e.g. Latn, Hans, Hant, Cyrl)."},{"n":"direction","r":true,"t":"enumeration","v":["LTR","RTL"],"info":"Text directionality. LTR for most languages, RTL for Arabic, Hebrew, etc."},{"n":"nativeName","r":true,"t":"string","info":"The locale's self-referential display name in its own language (e.g. Français, Español, 日本語)."}],"ext":"LookupEntity","related":["Market","Country"]},{"name":"Location","class":"Operational","subsystem":"CONNECT","area":"Organization","desc":"Physical or virtual point of presence (store, warehouse, online). The atomic unit for inventory and sales.","status":"draft","properties":[{"n":"baseCurrency","t":"string","info":"Default currency for transactions at this location."},{"n":"configuration","r":true,"t":"valueType","info":"Reference to this Location's configuration record in the CONFIG subsystem. Uses the ConfigurationRef value type (config: entityDetail → Config, templateCode: string). Resolved at runtime by services that need location-scoped configuration (POS behavior, fulfillment rules, pricing overrides)."},{"n":"contacts","r":true,"t":"array","info":"Array of LocationContact sub-documents."},{"n":"emails","r":true,"t":"array","info":"Email addresses associated with this location."},{"n":"fulfillmentMethods","r":true,"t":"array","v":["LocalDelivery","ShipToCustomer","StorePickup"],"info":"Supported fulfillment methods at this location."},{"n":"groups","r":true,"t":"array","info":"Location group memberships for reporting and operations."},{"n":"locationNo","r":true,"t":"string"},{"n":"marketId","t":"string","re":"Market","info":"Reference to the Market this location belongs to."},{"n":"name","r":true,"t":"string"},{"n":"phones","r":true,"t":"array","info":"Phone numbers associated with this location."},{"n":"postalAddresses","r":true,"t":"array","info":"Physical postal addresses for this location."},{"n":"priceLevel","t":"entityDetail","re":"Price Level","info":"Default pricing tier for this location."},{"n":"schedule","r":true,"t":"schema","info":"Operating schedule with regular hours and exception dates."},{"n":"shipToAddresses","r":true,"t":"array","info":"Ship-to addresses for receiving inventory at this location."},{"n":"status","r":true,"t":"schema","info":"LocationStatus inline schema capturing current document status with audit trail."},{"n":"stockBins","r":true,"t":"array","re":"StockBin","info":"One-to-many: a Location can have many StockBins; each StockBin belongs to exactly one Location."},{"n":"taxArea","t":"string","info":"Tax jurisdiction code for this location."},{"n":"timeZone","t":"string","info":"IANA time zone identifier (e.g. America/New_York)."},{"n":"type","r":true,"t":"enumeration","v":["Office","Store","Virtual","Warehouse"],"info":"Physical or logical type of the location."},{"n":"websiteUrl","t":"string","info":"Location-specific website URL. Renamed from website on 22 Sep 2026 to align with Vendor.websiteUrl and the registry's Url suffix pattern."}],"ext":"OperationalDocument","shopify":"Location resource","related":["Market","Franchise Group","Item Stock","StockBin"],"bv":{"rules":[{"rule":"Must be unique within the Company","when":"always","field":"Name","severity":"error"},{"rule":"operationalStatus may only be Open when documentStatus is Active; Draft and Archived Locations must have operationalStatus = Closed","when":"on save","field":"status.operationalStatus","severity":"error"}],"lifecycle":{"states":["Draft","Active","Archived"],"transitions":[{"to":"Active","from":"Draft","conditions":["Address is complete","At least one Market assigned"]},{"to":"Archived","from":"Active","conditions":["All stock transferred out","No pending transactions"]},{"to":"Active","from":"Archived","conditions":["Address is complete","At least one Market assigned","Name and locationNo do not collide with another Active Location in the Company"]}],"initialState":"Draft"},"calculations":[{"name":"stockBinCount","formula":"COUNT(StockBin WHERE location = this)","trigger":"query-time"},{"name":"employeeCount","formula":"COUNT(Employee WHERE location = this)","trigger":"query-time"},{"name":"itemStockCount","formula":"COUNT(Item Stock WHERE location = this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Cannot archive Location with non-zero inventory balances","entity":"Item Stock"},{"rule":"Must belong to at least one Market when Active","entity":"Market"}]},"inlineSchemas":[{"name":"LocationStatus","extends":"OperationalSubDocument","properties":[{"info":"Document lifecycle state of the Location. Drives lifecycle transitions (Draft -> Active -> Archived, with Archived -> Active reactivation).","name":"documentStatus","type":"enumeration","values":["Active","Archived","Draft"],"required":true},{"info":"Operational state independent of document lifecycle. Allows an Active Location to be temporarily Closed (e.g. holiday, remodel, weather) without archiving it.","name":"operationalStatus","type":"enumeration","values":["Closed","Open"],"required":true}]},{"name":"LocationSchedule","extends":"OperationalSubDocument","properties":[{"info":"Exception dates overriding the regular schedule (holidays, special hours).","name":"exceptionDates","type":"array","required":true},{"info":"Regular weekly operating hours per day.","name":"regularHours","type":"array","required":true},{"info":"IANA time zone for schedule interpretation.","name":"timeZone","type":"string"}]}]},{"name":"Location Traffic","class":"Operational","subsystem":"CONNECT","area":"Analytics","desc":"Measured foot traffic for one Location over one clock hour — the denominator behind conversion rate, capture rate, and traffic-driven labor scheduling. One row per Location per hour, keyed on location + intervalStart. Fed from people-counting sensors (ShopperTrak, RetailNext, Density, Aurora and similar) via vendor API or file import; sub-hour vendor intervals are summed at ingest, never stored at their native grain. Deliberately mutable: counting vendors restate prior-day figures after overnight recalibration and staff-pass filtering, and a restatement replaces the figure in place, incrementing revision and preserving one generation of history in previousEntryCount. dataQuality is what separates an observed zero from an offline counter — an hour with no sensor data is recorded as dataQuality='missing', never as entryCount 0, because a silent zero corrupts every average computed over it. Extends OperationalDocument rather than Identifiable (the Balance-class precedent) because the write path is an external, retry-prone integration that needs idmpKey for idempotent ingestion and identifiers for the vendor's own interval key — neither of which Inventory Position or Stock Cost Layer carry.","status":"draft","properties":[{"n":"businessDate","r":true,"t":"date","info":"Local retail trading date this hour is attributed to, as YYYY-MM-DD with no time or timezone component. Derived at ingest from intervalStart and timeZone against the Location's trading-day rules — NOT a plain calendar date, because a store trading until 02:00 attributes its 01:00 hour to the prior business date. Stored rather than derived at query time so traffic aggregates on the same day boundary as Sale without every consumer re-implementing trading-day logic."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it."},{"n":"dataQuality","r":true,"t":"enumeration","v":["actual","partial","interpolated","estimated","missing","suspect"],"info":"Provenance of the counts in this row, and the single most important property here. actual = complete observed data for the full hour. partial = fewer source intervals arrived than expected (see sourceIntervalCount); the figure understates. interpolated = filled by the vendor or by us from surrounding hours. estimated = modelled, not counted. missing = no data; entryCount must be 0 and the row MUST be excluded from averages and conversion denominators. suspect = received but failed a plausibility check (see the suspect rule in businessValidation). A dead counter reports nothing, and nothing arrives as zero — without this property that zero is indistinguishable from an hour when the store was genuinely empty, and every average, conversion rate, and year-over-year comparison computed across it is silently wrong."},{"n":"entryCount","r":true,"t":"integer","info":"Inbound crossings for the hour — the headline foot traffic figure. Non-negative. Whether staff passes are already removed is carried by isStaffExcluded, without which two vendors' numbers are not comparable. Must be 0 when dataQuality is 'missing', where it means 'no observation', not 'no shoppers'."},{"n":"exitCount","t":"integer","info":"Outbound crossings for the hour. Not reported by every counter (single-direction beam sensors report entries only). Where present it supports occupancy derivation and a sensor sanity check: entry and exit totals that diverge materially over a full trading day indicate a mis-aimed or failing counter."},{"n":"hourOfDay","r":true,"t":"integer","info":"Local clock hour the interval starts, 0-23, derived at ingest from intervalStart in timeZone. Denormalized deliberately: 'compare the 14:00 hour across the estate' is the most common traffic query there is, and deriving it per row at query time means timezone and DST arithmetic in every consumer. On the DST fall-back day two rows share the same hourOfDay and businessDate with different intervalStart values; on the spring-forward day one value is absent. intervalStart, not this, is the key."},{"n":"intervalStart","r":true,"t":"datetime","info":"ISO 8601 UTC timestamp of the start of the hour (e.g. 2026-08-31T18:00:00.000Z). Always aligned to the top of the hour. Together with location this is the natural key of the row and the only unambiguous ordering value — local hour is not unique across a DST transition."},{"n":"isOperatingHour","r":true,"t":"boolean","info":"Whether the Location was scheduled open for any part of this hour, resolved at ingest against Location.schedule including exceptionDates. Denormalized because evaluating a weekly schedule plus holiday exceptions per row is expensive in the analytics layer, and because the schedule in force at the time is the correct one — a schedule edited next year must not retroactively reclassify last year's hours. Traffic during a non-operating hour is normal (cleaning crew, deliveries, adjacent-tenant spill) and is a data-quality signal, not an error."},{"n":"isStaffExcluded","r":true,"t":"boolean","info":"Whether employee passes have already been removed from the counts by the source. Vendors differ: some filter staff via badge correlation or dwell heuristics, some do not. Without this flag a chain running two counting vendors cannot compare stores, and conversion rate is inflated at low-traffic stores where staff are a large share of crossings. False is the safe default."},{"n":"location","r":true,"t":"entityDetail","re":"Location","info":"The Location this traffic was measured at. entityDetail per location-ref-uses-entity-detail — location code and name are displayed on essentially every traffic report, and the join would otherwise run on every row of a large time series."},{"n":"locationTrafficNo","r":true,"t":"string","u":true,"info":"Human-readable identifier per entity-no-property. Composed from location code and interval (e.g. 'TRF-0042-20260831-14') so a row is identifiable in support and reconciliation without resolving a UUID."},{"n":"passerbyCount","t":"integer","info":"Crossings past the entrance without entering, where the counter supports an outside-the-door beam or camera field. The denominator for capture rate. Null for the majority of installations, which count entries only — a null here means not measured, never zero."},{"n":"peakOccupancy","t":"integer","info":"Highest concurrent occupancy observed during the hour, where the counter derives it from bidirectional counts. Used for capacity and staffing, not for conversion. Null when the counter is single-direction."},{"n":"previousEntryCount","t":"integer","info":"The entryCount this row replaced at the most recent restatement. One generation only — this is the deliberate limit of holding restatement history inside a mutable record rather than in a ledger, and it is what makes 'did last week's number move, and by how much' answerable without one. Null while revision is 0. If restatement history beyond one generation is ever needed, that is the signal to introduce a Traffic Observation ledger and demote this entity to its projection; see notes."},{"n":"revision","r":true,"t":"integer","info":"Number of times the vendor has restated this hour. 0 on first ingest, incremented on every replacement. A report is not reproducible without it: two runs a week apart over the same date range legitimately return different totals, and this is the only field that says so. Consumers caching traffic aggregates should key on it."},{"n":"sourceDeviceId","t":"string","info":"Counter or sensor identifier as issued by the counting vendor, carried as a plain string because no Traffic Counter entity exists in the registry. Populated only where a single counter feeds the Location; null where several counters were summed at ingest, which is the multi-entrance case. Vendor interval keys belong in the inherited identifiers array, not here."},{"n":"sourceIntervalCount","t":"integer","info":"How many source intervals were summed into this hour — 4 for a 15-minute feed, 1 for an hourly feed. What makes dataQuality='partial' actionable: three of four quarter-hours arriving is a figure that understates by a knowable amount rather than an unexplained dip. Null for feeds that do not expose interval boundaries."},{"n":"sourceSystem","t":"string","info":"Counting vendor or system that produced the figure (e.g. 'ShopperTrak', 'RetailNext', 'Density', 'Aurora'). Required in practice for any estate running more than one vendor, since isStaffExcluded, passerbyCount availability and restatement cadence all vary by vendor."},{"n":"sourceType","r":true,"t":"enumeration","v":["sensor","vendorApi","manualEntry","estimated","imported"],"info":"How the figure reached the platform. sensor = direct device telemetry. vendorApi = pulled or pushed from the counting vendor's aggregation service, the common case. manualEntry = keyed by a person, typically backfilling an outage. estimated = modelled with no observation behind it. imported = historical bulk load at onboarding. Distinct from dataQuality: sourceType is where it came from, dataQuality is how much to trust it."},{"n":"status","r":true,"t":"schema","info":"Settlement state of the figure. Follows the LocationTrafficStatus inline schema, mirroring the LocationStatus and PushNotificationLogStatus pattern. Intraday figures land provisional, settle to final after the vendor's overnight processing, and move to restated when replaced."},{"n":"timeZone","r":true,"t":"string","info":"IANA time zone identifier (e.g. America/Denver) in force at the Location when this hour was ingested. Snapshotted rather than read through to Location.timeZone so a relocated or corrected store does not retroactively re-interpret years of history — the local hour a row was recorded against must stay stable."}],"service":"APR Connect","notes":"PROPOSED — not yet reviewed. Created 31 Aug 2026 in response to \"add a traffic entity to track foot traffic in each location by hour\".\n\nSCOPE DECISION. Three shapes were offered; one entity was chosen. The two rejected alternatives are recorded here because the choice has a cost that will surface later.\n\n(a) Ledger + projection + sensor — a Traffic Observation ledger (immutable, one row per sensor per source interval per version), a Location Traffic projection folded from it, and a Traffic Counter operational entity for the physical device. This mirrors Stock Ledger -> Inventory Position and Push Device exactly, and is what the registry's own patterns point at for a restating feed.\n\n(b) Ledger + projection, no sensor entity.\n\n(c) One mutable entity — chosen.\n\nTHE COST OF (c), stated plainly. The feed restates: counting vendors reissue prior-day figures after overnight recalibration and staff-pass filtering. A mutable row absorbs a restatement by overwriting, so the platform holds one generation of history (previousEntryCount) and a count of how many times it moved (revision). That is enough to answer \"did this number change and by how much\" and not enough to answer \"what did the report say on the 3rd\" beyond one hop, or \"who restated it and why\" at all. If either of those becomes a requirement — most likely from a franchisee disputing a royalty or bonus figure computed off traffic — the migration is to introduce the Traffic Observation ledger from (a) and demote this entity to its projection. That migration is deliberately cheap by design: every property here except previousEntryCount and revision survives unchanged as projection state, exactly as Inventory Position holds balanceQty while Stock Ledger holds the movements. Nothing modelled here has to be unpicked.\n\nCLASS AND BASE SCHEMA. Operational extending OperationalDocument, not Balance extending Identifiable. The Balance-class precedents (Inventory Position, Stock Cost Layer) are internal projections written by a service that owns both sides, and they carry neither idmpKey nor identifiers because nothing external writes them. This entity's write path is an external, retry-prone integration — a vendor batch redelivered, a webhook replayed, a backfill re-driven — and a duplicated hour is a silently doubled denominator that makes conversion rate read half its true value. idmpKey and identifiers exist on OperationalDocument and nowhere else in that lineage, and they are the reason for the choice. modifiedBy/modifiedDate are also wanted here and are forbidden on a ledger, which is the second reason.\n\nAREA. Filed under Analytics, which until now held only Dashboard and Report — both Core, both configuration for views rather than measured data. This is the first measurement fact in that area and it changes what the area means. The alternative was Organization, where Location lives, but that area is organizational master data and traffic is not structure. If further measurement entities follow (labor hours, weather, queue time), Analytics is the right home for them; if none do, revisit.\n\nGRAIN IS FIXED AT ONE HOUR, DELIBERATELY. No granularity or intervalMinutes property. Vendors commonly emit 15-minute intervals; those are summed at ingest and sourceIntervalCount records how many contributed. Admitting a grain discriminator would put mixed-grain rows in one table, break the location + intervalStart natural key, and make every SUM wrong for anyone who forgets to filter — a failure that produces plausible numbers rather than an error. If 15-minute grain is genuinely needed for labor scheduling, it is a second entity or the ledger from (a), not a column here. Logged as a registry question.\n\nDATA QUALITY IS THE POINT. A people counter that dies reports nothing, and nothing arrives at the ingest boundary looking exactly like zero. Every property in the dataQuality/sourceIntervalCount/isOperatingHour group exists to keep that distinction, and the businessValidation rules enforce it: dataQuality='missing' forces entryCount to 0 and excludes the row from denominators. Without this, one dead sensor drags a store's hourly average down for as long as it stays dead and nobody notices, because the number stays plausible.\n\nSTAFF EXCLUSION IS NOT COSMETIC. isStaffExcluded is required and defaults false because vendors differ on whether employee passes are filtered. At a low-traffic store staff can be a large share of crossings, so an unflagged mixed-vendor estate produces conversion rates that are not comparable between stores — and the comparison is the entire purpose of the metric.\n\nNO CONVERSION RATE PROPERTY. Conversion, capture rate and sales-per-visitor are query-time calculations against Sale, documented in businessValidation.calculations. Storing them would freeze a figure whose numerator keeps moving as late sales, returns and voids post.\n\nNOT DONE. (1) No Traffic Counter entity, so sensor identity is a plain string in sourceDeviceId and a multi-entrance store's per-door detail is lost at ingest. (2) No zone or entrance dimension — store total only. (3) No retention policy: one row per location per hour is ~8,760 rows per store per year, which is modest at 100 stores and material at 5,000; decide before the first backfill. (4) service declared as \"APR Connect\" by inference from the area; confirm against the real service topology. (5) No architecture-layer node for a traffic ingestion service. (6) No Data Flow documented for the vendor feed.","related":["Location","Sale","Employee","Report","Dashboard"],"bv":{"rules":[{"rule":"Must be unique within Company per Location — one row per Location per hour. Enforced together with location; the inherited idmpKey composes company + location + intervalStart + sourceSystem so a replayed vendor batch collides rather than duplicating the hour","when":"create","field":"IntervalStart","severity":"error"},{"rule":"Must be aligned to the top of the hour (minutes, seconds and milliseconds zero). Sub-hour vendor intervals are summed at ingest, never stored","when":"always","field":"IntervalStart","severity":"error"},{"rule":"Must be greater than or equal to zero","when":"always","field":"EntryCount","severity":"error"},{"rule":"Must be 0 when dataQuality is 'missing'. A missing hour is an absence of observation and must be excluded from averages and conversion denominators, never read as an observed zero","when":"always","field":"EntryCount","severity":"error"},{"rule":"Must be 'partial' when sourceIntervalCount is present and less than the feed's expected intervals per hour","when":"on ingest","field":"DataQuality","severity":"error"},{"rule":"Should be set to 'suspect' when entryCount exceeds a plausibility threshold for the Location — a common counter failure mode is a stuck beam emitting thousands of crossings per hour, which is not distinguishable from a genuine surge without this check","when":"on ingest","field":"DataQuality","severity":"warning"},{"rule":"May only increase, and must increment by exactly 1 on each restatement","when":"update","field":"Revision","severity":"error"},{"rule":"Must be populated when revision is greater than 0, and must be null when revision is 0","when":"always","field":"PreviousEntryCount","severity":"error"},{"rule":"documentStatus may not return to 'provisional' once 'final' or 'restated'","when":"update","field":"Status","severity":"error"},{"rule":"documentStatus must be 'restated' when revision is greater than 0","when":"always","field":"Status","severity":"error"},{"rule":"Must equal the local clock hour of intervalStart interpreted in timeZone, and must be between 0 and 23","when":"always","field":"HourOfDay","severity":"error"},{"rule":"Must be a valid IANA time zone identifier","when":"always","field":"TimeZone","severity":"error"},{"rule":"Should not diverge from entryCount by more than a configured tolerance when aggregated over a full business date — sustained divergence indicates a mis-aimed or failing bidirectional counter","when":"on ingest","field":"ExitCount","severity":"warning"},{"rule":"Should be consistent across all rows for a Location within a business date — a mid-day change means the day's figures are not internally comparable","when":"on ingest","field":"IsStaffExcluded","severity":"warning"}],"lifecycle":{"states":["Provisional","Final","Restated"],"transitions":[{"to":"Final","from":"Provisional","conditions":["Vendor overnight processing complete for the business date","All expected source intervals received, or dataQuality set to partial/missing"]},{"to":"Restated","from":"Final","conditions":["Vendor issues a corrected figure for the hour","previousEntryCount captured","revision incremented"]},{"to":"Restated","from":"Restated","conditions":["Vendor issues a further correction; only the immediately preceding value is retained"]}],"initialState":"Provisional"},"calculations":[{"name":"conversionRate","formula":"COUNT(Sale WHERE location = this.location AND fiscalDate = this.businessDate AND HOUR(transactionDate) = this.hourOfDay) / NULLIF(entryCount, 0), excluding rows where dataQuality IN ('missing','suspect')","trigger":"query-time"},{"name":"captureRate","formula":"entryCount / NULLIF(passerbyCount, 0)","trigger":"query-time"},{"name":"salesPerVisitor","formula":"SUM(Sale.total WHERE location = this.location AND fiscalDate = this.businessDate AND HOUR(transactionDate) = this.hourOfDay) / NULLIF(entryCount, 0)","trigger":"query-time"},{"name":"netOccupancyChange","formula":"entryCount - exitCount, null when exitCount is null","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"location must reference an active Location in the same Company. isOperatingHour is resolved at ingest against Location.schedule including exceptionDates, and timeZone is snapshotted from Location.timeZone at that moment — neither is read through afterwards","entity":"Location"},{"rule":"Conversion rate joins Sale on location + businessDate + local hour. Traffic rows with dataQuality 'missing' or 'suspect' must be excluded from the denominator rather than treated as zero, otherwise conversion is reported as infinite or as a spurious 100%","entity":"Sale"},{"rule":"Traffic-driven labor scheduling reads this entity as the demand curve. isStaffExcluded=false means staff movements are inside the demand signal, which biases scheduling toward already-staffed hours","entity":"Employee"}]},"inlineSchemas":[{"name":"LocationTrafficStatus","schema":"schemas/location-traffic/LocationTrafficStatus.ts","extends":"OperationalSubDocument","properties":[{"info":"Settlement state of the figure. provisional = intraday or same-day ingest, subject to the vendor's overnight recalibration and staff-pass filtering; safe for operational dashboards, not for reporting. final = the vendor's settled figure for the hour; the default state for reporting. restated = a final figure has since been replaced, revision > 0 and previousEntryCount holds the value it replaced. A row may cycle final -> restated -> restated; it never returns to provisional.","name":"documentStatus","type":"enumeration","values":["provisional","final","restated"],"required":true},{"info":"User ID or system principal that last changed the status. System-set for vendor-feed transitions; a user ID only where an operator manually finalized or corrected an hour.","name":"changedBy","type":"string"},{"info":"ISO 8601 UTC timestamp of the last status change. On a restated row this is the restatement time, which is why no separate restatedAt property exists.","name":"changedDate","type":"datetime"}]}],"inheritance":{"inherited":["id","company","recentActions","createdBy","createdDate","customData","franchiseGroups","identifiers","idmpKey","isDeleted","modifiedBy","modifiedDate","notes","tags","uniqueValues"]}},{"name":"Loyalty Program","class":"Dictionary","subsystem":"CONNECT","area":"Sales & Orders","desc":"A customer loyalty or rewards program. Customers may be enrolled in one or more programs that influence pricing, promotions, or point accrual.","status":"stub","properties":[{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. A loyalty program carries accrued member value, so the boundary is a financial control as well as a data one."},{"n":"name","r":true,"t":"string","u":true}],"ext":"LookupEntity","related":["Customer"]},{"name":"Market","class":"Operational","subsystem":"CONNECT","area":"Organization","desc":"Defines fiscal rules and hierarchy. Drives currency, tax treatment, catalogs, and price/cost levels. Supports parent-child nesting for regions.","status":"reviewed","properties":[{"n":"baseCurrency","r":true,"t":"entityDetail","re":"Currency","info":"The canonical accounting currency for all prices, costs, and monetary amounts within this market."},{"n":"catalogs","r":true,"t":"array","re":"Catalog"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it."},{"n":"defaultCostLevel","t":"entityDetail","re":"Cost Level"},{"n":"defaultPriceLevel","t":"entityDetail","re":"Price Level"},{"n":"duties","t":"decimal"},{"n":"localization","r":true,"t":"schema","info":"Localization settings for this market — default locale, supported locales, and display formatting preferences."},{"n":"marketNo","r":true,"t":"integer"},{"n":"name","r":true,"t":"string"},{"n":"parentMarketId","t":"entityRef","re":"Market"},{"n":"salesTax","t":"decimal"}],"related":["Location","Catalog","Currency","Locale","Price Level","Cost Level","Business Entity"],"bv":{"rules":[{"rule":"Must be unique within the Company","when":"always","field":"Name","severity":"error"}],"lifecycle":null,"calculations":[{"name":"locationCount","formula":"COUNT(Location WHERE market = this)","trigger":"query-time"},{"name":"catalogCount","formula":"COUNT(catalogs)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Cannot delete a Market with assigned Locations","entity":"Location"},{"rule":"Removing a Market may orphan Price Level assignments","entity":"Price Level"}]},"inlineSchemas":[{"name":"LocalizationSettings","desc":"Display and formatting preferences for a market's locale configuration.","properties":[{"n":"defaultLocale","r":true,"t":"entityDetail","re":"Locale","info":"The primary locale for this market. Used as the fallback when no translation exists for a requested locale."},{"n":"supportedLocales","r":true,"t":"array","re":"Locale","info":"All locales available for content and UI in this market."},{"n":"dateFormat","r":true,"t":"enumeration","v":["MM/DD/YYYY","DD/MM/YYYY","YYYY-MM-DD"],"info":"Date display format for this market."},{"n":"timeFormat","r":true,"t":"enumeration","v":["12h","24h"],"info":"Time display format — 12-hour (AM/PM) or 24-hour clock."},{"n":"decimalSeparator","r":true,"t":"enumeration","v":[".",","],"info":"Character used as the decimal point in numeric display (period for US/UK, comma for EU)."},{"n":"thousandsSeparator","r":true,"t":"enumeration","v":[",","."," ",""],"info":"Character used as the thousands grouping separator in numeric display."},{"n":"measurementSystem","r":true,"t":"enumeration","v":["Imperial","Metric"],"info":"Default measurement system for weights, dimensions, and distances."},{"n":"defaultTimezone","r":true,"t":"string","info":"IANA timezone identifier (e.g. America/New_York, Europe/London). Used for date/time display and scheduling defaults."}]}],"inheritance":{"childEntity":"Market","parentEntity":"Market","overridableDefaults":["defaultCostLevel","defaultPriceLevel"]}},{"name":"Matched Invoice Set","class":"Operational","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"Three-way match aggregate linking a Purchase Order, its Goods Receipts, and the corresponding Vendor Invoice for AP reconciliation.","status":"stub","properties":[{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. Always matches the company of the documents it matches together."},{"n":"purchaseOrder","r":true,"t":"entityRef","re":"Purchase Order"},{"n":"purchaseReceipts","r":true,"t":"array","re":"Goods Receipt"},{"n":"vendorInvoice","r":true,"t":"entityRef","re":"Vendor Invoice"}],"related":["Purchase Order","Goods Receipt","Vendor Invoice"]},{"name":"Media","class":"Operational","subsystem":"CONNECT","area":"Platform","desc":"Any stored binary asset in the platform — product imagery and video, franchise operations documents, content attachments, brand assets. Generalized on 31 Aug 2026 from an image/video-only product media library into the platform's single asset entity, so that Content Post, Content Page, Announcement and Chat Message attachments do not each mint their own blob store. TWO INDEPENDENT DISCRIMINATORS, deliberately not collapsed into one: mediaType describes what the BYTES are (Image, Video, Document, Audio, Archive) and drives the processing pipeline and which metadata applies; purpose describes what the asset is FOR (ProductMedia, ContentAsset, Attachment, OperationsDocument, BrandAsset, Avatar) and drives permissions, retention and which surface lists it. They are not derivable from each other — purpose='ProductMedia' implies an image or video, but purpose='Attachment' can be any mediaType at all — so a single combined enum would be a cross-product that cannot express the real constraints. ACCESS IS RESTRICTED BY DEFAULT. The entity originated as product imagery, which is effectively public: it publishes to Shopify and serves from a CDN over unauthenticated URLs. Now that the same entity holds franchise P&Ls and operations manuals, storageKey is the authoritative location and url is a derived, signed, short-lived value — never a durable public link except where visibility='public' is explicitly set. Inheriting the product-image serving path for a restricted document is the failure mode this design exists to prevent.","status":"draft","properties":[{"n":"alt","t":"string","info":"Alt text for accessibility and SEO. Required when mediaType='Image' — conditionally required rather than always required, because alt text on a PDF or a ZIP archive is meaningless. Enforced by business validation rather than by the required flag."},{"n":"checksum","t":"string","info":"SHA-256 of the stored object, used for duplicate detection on upload and for integrity verification. Null for externally hosted assets, which the platform does not store and cannot hash."},{"n":"fileErrors","r":true,"t":"array<string>","info":"Error messages from file upload or processing failures. Empty array default. Per array-must-be-required."},{"n":"fileStatus","r":true,"t":"enumeration","v":["Queued","Uploading","Uploaded","Processing","Ready","Failed"],"info":"Upload and processing lifecycle state of the underlying file."},{"n":"folder","t":"entityDetail","re":"Media Folder","info":"Optional folder in the media library tree, as an entityDetail snapshot (id + code + name) so the Files surface can render breadcrumbs without a lookup per row. Null for assets addressed through their referencing entity rather than browsed — product imagery uploaded against a Product, chat attachments — which therefore never appear in the Files tree."},{"n":"isHostedExternal","r":true,"t":"boolean","info":"Whether the asset is hosted on an external CDN rather than platform storage. Only meaningful for Image and Video. An externally hosted asset has no storageKey the platform controls and therefore cannot be served with a signed URL, so it must not be used where visibility is 'restricted'. Per boolean-must-be-required."},{"n":"mediaErrors","r":true,"t":"array<string>","info":"Errors encountered during type-specific processing (thumbnail generation, transcoding, text extraction). Empty array default."},{"n":"mediaNo","r":true,"t":"string","u":true,"info":"Human-readable identifier, unique within Company. Per entity-no-property convention — absent before the 31 Aug 2026 generalization, which was a standing convention violation."},{"n":"mediaStatus","r":true,"t":"schema","info":"MediaStatus inline schema. Processing status of the asset, with the actor and timestamp of the last status change."},{"n":"mediaType","r":true,"t":"enumeration","v":["Image","Video","Document","Audio","Archive"],"info":"SHAPE discriminator — what the bytes are. Drives the processing pipeline and determines which metadata properties apply (previewUrl, thumbHash and dimensions are Image-only; transcoding applies to Video and Audio). Extended from Image|Video on 31 Aug 2026. Orthogonal to purpose."},{"n":"mediaWarnings","r":true,"t":"array<string>","info":"Non-fatal warnings from processing (suboptimal resolution, missing metadata, unrecognised document structure). Empty array default."},{"n":"mimeType","r":true,"t":"string","info":"MIME type of the stored file (e.g. image/jpeg, video/mp4, application/pdf)."},{"n":"originalMediaSource","r":true,"t":"schema","info":"MediaSource inline schema. Original source metadata as uploaded — filename, size, format, MIME type, source URL, and pixel dimensions where the type has them."},{"n":"previewUrl","t":"string","info":"URL of a lower-resolution preview. Only applicable to Image, and to the generated first-page render of a Document. Subject to the same visibility rules as url — a preview of a restricted asset is still a disclosure."},{"n":"purpose","r":true,"t":"enumeration","v":["ProductMedia","ContentAsset","Attachment","OperationsDocument","BrandAsset","Avatar"],"info":"PURPOSE discriminator — what the asset is for. Drives default permissions, retention policy and which surface lists it. Orthogonal to mediaType: ProductMedia implies an Image or Video, but Attachment can be any shape. Set at upload from the originating surface and not normally changed, because changing it changes the governing retention policy."},{"n":"storageKey","r":true,"t":"string","info":"Authoritative location of the stored object in platform storage. This, not url, is the durable identity of the bytes. Immutable once fileStatus='Ready'. For externally hosted assets this holds the external reference and the platform serves no signed URL."},{"n":"thumbHash","t":"string","info":"ThumbHash-encoded placeholder for progressive image loading. Only applicable to Image."},{"n":"url","t":"string","info":"Derived access URL. For visibility='public' this is a durable CDN link; for 'authenticated' and 'restricted' it is a short-lived signed URL generated per request from storageKey and must never be persisted or shared. NOT required — changed from required on 31 Aug 2026, because a restricted operations document has no durable URL at all."},{"n":"visibility","r":true,"t":"enumeration","v":["public","authenticated","restricted"],"info":"Access class. 'public' = durable unauthenticated CDN URL, appropriate for product imagery that publishes to Shopify. 'authenticated' = any signed-in User in the Company. 'restricted' = only Users whose franchise group assignments overlap the asset's inherited franchiseGroups. DEFAULTS TO 'restricted' when not supplied — the safe default must be the implicit one, because the alternative fails open."}],"ext":"OperationalDocument","notes":"PROPOSED — not yet reviewed. Rewritten 31 Aug 2026 to generalize the product media library into the platform's single asset entity.\n\nWHY THIS WAS A REWRITE RATHER THAN AN EDIT: the registry's update_entity cannot change an entity's base schema, so re-parenting from Identifiable to OperationalDocument required delete and recreate under the same name. All prior properties, inline schemas, lifecycle states and cross-entity constraints were carried forward; nothing was dropped.\n\nTHE RE-PARENT WAS NOT COSMETIC. On Identifiable, Media had id and nothing else — no franchiseGroups, no company, no soft delete, no audit trail. That was survivable for product imagery, which is effectively public and owned by the Product that references it. It is not survivable for a franchise operations document, which needs franchise-group scoping to be visible to the right operators and invisible to the wrong ones. Two standing convention violations were fixed in passing: company-scoping-required (Media declared no company and inherited none) and entity-no-property (no mediaNo). Both were pre-existing.\n\nNAMING DEBT, ACCEPTED KNOWINGLY: the entity is still called Media while now holding PDFs, spreadsheets and archives. Asset is the correct name. The registry has no rename operation and the name is referenced by the ProductMedia inline schema on Product, Item and Product Group, so the rename is deferred rather than declined — see the open question logged on this.\n\nNOT DONE IN THIS PASS: shape-specific metadata inline schemas. width and height were relaxed to optional on MediaSource as a stopgap, but the right model is separate ImageMetadata / VideoMetadata / DocumentMetadata inline schemas selected by mediaType, so that a Document can carry pageCount and extraction status without an Image carrying null columns for them. Logged as an open question.\n\nOpen questions: (1) rename to Asset when a rename path exists; (2) shape-specific metadata schemas; (3) do Product, Item and Product Group need their ProductMedia inline schema updated to assert purpose='ProductMedia', or is the cross-entity constraint sufficient; (4) versioning and retention policy per purpose; (5) virus scanning is referenced in the Failed transition but no scan status property exists.","related":["Product","Item","Product Group","Media Folder","Announcement","Content Post","Content Page","Chat Message"],"bv":{"rules":[{"rule":"Required when mediaType = 'Image'; not required for Document, Audio or Archive","when":"always","field":"Alt","severity":"error"},{"rule":"Defaults to 'restricted' when not supplied by the uploader. A missing visibility must never resolve to 'public'","when":"create","field":"Visibility","severity":"error"},{"rule":"Must not be 'restricted' when isHostedExternal = true — an externally hosted asset cannot be served through a signed URL","when":"always","field":"Visibility","severity":"error"},{"rule":"Immutable once fileStatus = 'Ready'","when":"update","field":"StorageKey","severity":"error"},{"rule":"Must be null or a signed short-lived value when visibility != 'public'; a durable URL on a restricted asset is a disclosure defect","when":"always","field":"Url","severity":"error"},{"rule":"purpose = 'ProductMedia' requires mediaType in (Image, Video)","when":"always","field":"Purpose","severity":"error"},{"rule":"purpose = 'Avatar' requires mediaType = 'Image' and visibility != 'restricted'","when":"always","field":"Purpose","severity":"error"},{"rule":"Changing purpose after create changes the governing retention policy and requires confirmation","when":"update","field":"Purpose","severity":"warning"}],"lifecycle":{"states":["Pending","Processing","Ready","Failed"],"transitions":[{"to":"Processing","from":"Pending","conditions":["File upload completed"]},{"to":"Ready","from":"Processing","conditions":["Type-specific processing complete: thumbnails for Image, transcode for Video/Audio, first-page render and text extraction for Document, none for Archive"]},{"to":"Failed","from":"Processing","conditions":["Processing error (corrupt file, unsupported format, failed virus scan)"]},{"to":"Pending","from":"Failed","conditions":["Re-upload initiated"]}],"initialState":"Pending"},"calculations":[],"crossEntityConstraints":[{"rule":"Deleting media referenced by a Product requires confirmation","entity":"Product"},{"rule":"Deleting media referenced by an Item requires confirmation","entity":"Item"},{"rule":"A Product, Item or Product Group media reference accepts only assets with mediaType in (Image, Video) and purpose = 'ProductMedia'","entity":"Product"},{"rule":"Assets referenced from ContentDocument attachments or heroMedia must have purpose in ('ContentAsset', 'Attachment', 'BrandAsset') and visibility compatible with the referencing document's resolved audience","entity":"Content Post"},{"rule":"The folder's defaultVisibility applies at upload only; moving an asset between folders never changes its visibility","entity":"Media Folder"}]},"inlineSchemas":[{"name":"MediaSource","schema":"schemas/media/MediaSource.ts","properties":[{"info":"Original filename as uploaded.","name":"filename","type":"string"},{"info":"File size in bytes.","name":"fileSize","type":"integer","required":true},{"info":"File format (e.g. jpeg, png, mp4, webm, pdf, docx, zip).","name":"format","type":"string","required":true},{"info":"Height in pixels. Only present for Image and Video — changed from required on 31 Aug 2026, because a PDF has no pixel height.","name":"height","type":"integer"},{"info":"MIME type of the original file.","name":"mimeType","type":"string","required":true},{"info":"URL of the original uploaded file before any processing.","name":"url","type":"string","required":true},{"info":"Width in pixels. Only present for Image and Video — changed from required on 31 Aug 2026.","name":"width","type":"integer"}]},{"name":"MediaStatus","schema":"schemas/media/MediaStatus.ts","properties":[{"info":"Current processing state of the asset.","name":"status","type":"enumeration","values":["Pending","Processing","Ready","Failed"],"required":true},{"info":"User or system actor who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"}]}]},{"name":"Media Folder","class":"Dictionary","subsystem":"CONNECT","area":"Platform","desc":"Hierarchical folder in the media library, giving the Files surface a navigable tree over Media records. Extends TaxonomyEntity, reusing the same parent/children/depth/path materialization that Product Category uses, rather than inventing a second tree implementation. A Media record carries an optional folder reference; unfoldered assets (product imagery uploaded against a Product, chat attachments) are addressed through their referencing entity and never appear in the Files tree. Folders carry a defaultVisibility that newly uploaded assets inherit, which is what makes 'restricted by default' operable in the UI — an operator drops a document into the Franchise Operations folder and it is private without anyone remembering to set a flag. Folder visibility is a default for new uploads, NOT an access control on existing contents: moving an asset between folders never silently changes its visibility, because that would make a drag-and-drop a disclosure event.","status":"draft","properties":[{"n":"defaultVisibility","r":true,"t":"enumeration","v":["public","authenticated","restricted"],"info":"Visibility applied to Media records newly created in this folder when the uploader does not set one explicitly. Defaults to 'restricted'. Applies at upload time only — changing it does not retroactively alter existing contents, and moving an asset into this folder does not change that asset's visibility."},{"n":"isSystemFolder","r":true,"t":"boolean","info":"When true, the folder is created and maintained by the platform (e.g. Product Imagery, Chat Attachments) and cannot be renamed, moved or deleted by operators. Per boolean-must-be-required."},{"c":true,"n":"mediaCount","r":true,"t":"integer","info":"Calculated rollup. COUNT(Media WHERE folder = this AND isDeleted = false). Excludes descendant folders — the tree total is computed by summing over path prefix at query time. Query-time."}],"ext":"TaxonomyEntity","notes":"PROPOSED — not yet reviewed. Created 31 Aug 2026 alongside the Media generalization, to give the Files surface a folder tree.\n\nClass is Dictionary because the registry's entity class enum offers no 'Taxonomy' value even though Product Category and Product Type carry that class — see the open question logged on this. The intended class is Taxonomy; Dictionary is a placeholder that should be corrected when the enum is reconciled.\n\nOpen questions: (1) should folders carry their own franchiseGroups, or inherit scoping purely from the Media records inside them? TaxonomyEntity provides company but not franchiseGroups, so a franchise-scoped folder tree is not currently expressible. (2) versioning — if Media gains version history, does the folder show the latest only?","related":["Media"],"bv":{"rules":[{"rule":"Defaults to 'restricted' when not supplied by the uploader","when":"create","field":"DefaultVisibility","severity":"error"},{"rule":"System folders cannot be renamed, moved or deleted","when":"update","field":"IsSystemFolder","severity":"error"}],"calculations":[{"name":"mediaCount","formula":"COUNT(Media WHERE folder = this AND isDeleted = false)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Deleting a folder that still contains Media is refused; contents must be moved or deleted first","entity":"Media"},{"rule":"Moving Media into this folder does not change the asset's visibility — the folder default applies to new uploads only","entity":"Media"}]}},{"name":"Notification Preference","class":"Operational","subsystem":"CONNECT","area":"Messaging","desc":"Per-User opt-in settings governing which notification categories reach which channels, plus quiet hours. Exactly one record per User. Evaluated at fan-out time by every notification source — Announcement publish, Chat Message @mention, workflow approval — before a Push Notification Log entry is written; an excluded recipient is logged as status='suppressed' rather than silently skipped. Carries the override contract that Announcement.priority already implies: 'urgent' priority and 'compliance' category bypass category opt-out and quiet hours, because a mandatory-acknowledgement policy notice that a User can mute is not a compliance control.","status":"draft","properties":[{"n":"categoryPreferences","r":true,"t":"array","info":"CategoryPreference inline schema entries — one per notification category, each carrying per-channel opt-in flags. Empty array means no explicit choices recorded and tenant defaults apply. Per array-must-be-required."},{"n":"isDoNotDisturbEnabled","r":true,"t":"boolean","info":"Master mute across every category and channel. Overridden only by urgent-priority and compliance-category notifications. Default false. Per boolean-must-be-required."},{"n":"notificationPreferenceNo","r":true,"t":"string","u":true,"info":"Human-readable identifier, unique within Company. Per entity-no-property convention."},{"n":"quietHoursEnd","t":"string","info":"Local wall-clock end of the quiet window, HH:mm 24-hour. Stored as a bare local time rather than a datetime because it recurs daily and must survive DST transitions — evaluated against timezone, not UTC. Null when quiet hours are not configured; must be set together with quietHoursStart."},{"n":"quietHoursStart","t":"string","info":"Local wall-clock start of the quiet window, HH:mm 24-hour. A window where end is earlier than start spans midnight (e.g. 22:00–07:00). Pushes falling inside the window are suppressed, not deferred — queue-and-release is an open question."},{"n":"timezone","t":"string","info":"IANA timezone identifier (e.g. 'America/Denver') used to evaluate quiet hours. Required when quiet hours are configured. Falls back to the User's primary Location timezone when null, which is the right default for store-level staff and the wrong one for travelling corporate users — hence the explicit override."},{"n":"user","r":true,"t":"entityRef","u":true,"re":"User","info":"The User these preferences belong to. Unique — one preference record per User, created lazily on first notification or first settings visit."}],"service":"APR Connect","ext":"OperationalDocument","notes":"PROPOSED — not yet reviewed. Modelled as an entity rather than an inline schema on User deliberately: User is a Core entity in the ACCESS subsystem and this is CONNECT > Messaging behaviour, so hanging messaging config off the identity record would couple the two subsystems. The cost is a lazily-created row per User and a join at fan-out time; mitigate with a cache keyed on user id. Also referenced by Push Notification Log — an excluded recipient is written with status='suppressed' rather than omitted from the log.\n\nOpen questions: (1) QUIET HOURS SEMANTICS — suppress or defer? Current spec suppresses. Deferring means a queue and a release job, and a shift-worker waking to nine stale notifications; suppressing means they never learn a thing happened. Announcement's own expireAt may make deferral moot for the announcement case. (2) Tenant-level defaults — where do they live? Company configuration, or a Notification Preference record with a null user acting as the template? (3) Should Franchisors be able to force categories on for their franchisees (mandatory operational alerts), and if so is that a Company-level policy or a per-category isLocked flag here? (4) The compliance-not-opt-out rule is asserted here but Announcement.category is author-set — confirm an author cannot escalate a marketing blast to 'compliance' to defeat opt-out.","related":["User","Email Log","Announcement"],"bv":{"rules":[{"rule":"Must be unique within Company — at most one Notification Preference per User","when":"create","field":"User","severity":"error"},{"rule":"Must be set together with quietHoursEnd — neither may be set alone","when":"always","field":"QuietHoursStart","severity":"error"},{"rule":"Must be a valid IANA identifier, and must be set when quiet hours are configured","when":"always","field":"Timezone","severity":"error"},{"rule":"At most one entry per category — no duplicate category rows","when":"always","field":"CategoryPreferences","severity":"error"},{"rule":"A compliance-category entry may not set isPushEnabled or isEmailEnabled to false — compliance notices are not opt-out","when":"always","field":"CategoryPreferences","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"user must reference a valid User within the same Company","entity":"User"},{"rule":"Announcement.priority='urgent' and category='compliance' bypass categoryPreferences, isDoNotDisturbEnabled, and quiet hours","entity":"Announcement"},{"rule":"isEmailEnabled=false for a category prevents the Email Log entry being created for that category's sends","entity":"Email Log"}]},"inlineSchemas":[{"name":"CategoryPreference","schema":"schemas/notification-preference/CategoryPreference.ts","properties":[{"info":"Notification category this row governs. Matches the category enumeration on Push Notification Log and aligns with Announcement.category.","name":"category","type":"enumeration","values":["operational","policy","training","marketing","system","compliance","chat","workflow"],"required":true},{"info":"Whether push delivery is permitted for this category.","name":"isPushEnabled","type":"boolean","required":true},{"info":"Whether email delivery is permitted for this category. Suppression here prevents the Email Log entry from being created.","name":"isEmailEnabled","type":"boolean","required":true},{"info":"Whether the notification appears in the in-app feed and portal banner surfaces.","name":"isInAppEnabled","type":"boolean","required":true}]}],"inheritance":{"inherited":["id","company","franchiseGroups","idmpKey","identifiers","uniqueValues","customData","tags","notes","recentActions","isDeleted","createdBy","createdDate","modifiedBy","modifiedDate"]}},{"name":"Option","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"A configurable choice on a product (e.g. Engraving, Gift Wrap). OptionValues on an Item carry relative price/cost adjustment deltas that modify line-level pricing at time of sale/purchase — these are NOT transactional and do not write to Price Ledger or Cost Ledger.","status":"draft","properties":[{"n":"alias","t":"string"},{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. Option values carry price and cost modifier deltas, so a cross-tenant read would change what a customer is charged."},{"n":"description","t":"string"},{"n":"group","t":"string","info":"Logical grouping label for related options."},{"n":"name","r":true,"t":"string","u":true},{"n":"optionValues","t":"array<OptionValue>","info":"The set of selectable values for this option (e.g. for a 'Color' option: Red, Blue, Green)."},{"n":"vendorCode","t":"string"}],"ext":"LookupEntity","related":["Product","Item"],"bv":{"rules":[],"lifecycle":null,"calculations":[{"name":"productCount","formula":"COUNT(Product WHERE options CONTAINS this)","trigger":"query-time"}],"crossEntityConstraints":[]},"inlineSchemas":[{"name":"OptionValue","desc":"A single selectable value within an Option's value set. Extends LookupEntityValue — inherits id, identifiers, code, name, aliases, isActive, isDeleted, isDefault, sequence, audit fields, and customData.","schema":"schemas/product-common/OptionValue.ts","extends":"LookupEntityValue","properties":[{"info":"A +/- difference the option value applies to the line-level price. This is a relative modifier, not a transactional adjustment — does not write to the Price Ledger.","name":"priceModifier","type":"decimal"},{"name":"vendorCostModifiers","type":"array<VendorOptionCostModifier>"}]},{"name":"VendorOptionCostModifier","schema":"schemas/product-common/VendorOptionCostModifier.ts","properties":[{"info":"A +/- difference the option value applies to the vendor cost. This is a relative modifier, not a transactional adjustment — does not write to the Cost Ledger.","name":"costModifier","type":"decimal","required":true},{"name":"vendor","type":"entityDetail","required":true,"relatedEntity":"Vendor"}]}]},{"name":"Organization","class":"Core","subsystem":"ALLPOINT","area":"Organization","desc":"Top-level SaaS account entity. The root container for Companies, Business Entities, and platform-wide configuration. An Organization represents the subscriber — the entity that holds a contract with All Point, modeled explicitly as Subscription. Multi-tenancy is enforced at the Company level, not the Organization level — and so deploymentModel (Shared/Dedicated) is a Company property, not an Organization property — but the COMMERCIAL relationship sits here, one level above the tenant boundary: the active Subscription funds every LicenseGrant held by installations inside this Organization's Companies, and its deploymentEntitlement gates whether those Companies may be provisioned Dedicated. An Organization can hold multiple Companies, and in principle different Companies under the same Organization can sit on different Clients with different deployment models. Supports parent/child self-referencing to model franchise relationships — a Franchisor Organization is the parent of its Franchisee Organizations, enabling cross-org catalog distribution, reporting, and shared product masters while maintaining full data isolation per tenant. Note that a Franchisee Organization may exist purely for hierarchy and be billed through its Franchisor, in which case it holds no Subscription of its own.","status":"stub","properties":[{"n":"children","r":true,"t":"array","re":"Organization","info":"Child Organizations in a franchise hierarchy. A Franchisor Org has Franchisee Orgs as children. Empty for standalone or leaf organizations."},{"n":"companies","r":true,"t":"array","re":"Company","info":"One-to-many. An Organization has many Companies; a Company belongs to exactly one Organization."},{"n":"configuration","r":true,"t":"valueType","info":"Reference to this Organization's configuration record in the CONFIG subsystem. Uses the ConfigurationRef value type (config: entityDetail → Config, templateCode: string). Holds org-wide platform settings inherited by child Companies unless overridden."},{"n":"name","r":true,"t":"string"},{"n":"organizationNo","r":true,"t":"string","u":true,"info":"Human-readable business identifier"},{"n":"parentOrganization","t":"entityRef","re":"Organization","info":"Self-referencing parent Organization. Null for root organizations (e.g. a standalone brand or a Franchisor). Set to the Franchisor Organization for Franchisee Organizations. Enables franchise hierarchy without conflating the business relationship with SaaS tenancy."},{"n":"status","r":true,"t":"schema","info":"Lifecycle status of the organization. Tracks current state and who/when it was last changed."},{"n":"subscriptions","r":true,"t":"array","re":"Subscription","info":"Commercial contracts held by this subscriber. One-to-many: an Organization accumulates Subscriptions over time as contracts are renegotiated, with at most one status 'active' at any moment. Empty array for an Organization that has not yet contracted (a Franchisee Organization created for hierarchy purposes but billed through its Franchisor, for example). The active Subscription is what funds every LicenseGrant held by installations inside this Organization's Companies, and its deploymentEntitlement is what gates whether those Companies may be provisioned Dedicated."},{"n":"type","t":"enumeration","v":["Standard","Franchisor","Franchisee"],"info":"Discriminator for organizational role. Standard: independent brand. Franchisor: parent organization that licenses to franchisees. Franchisee: operates under a franchisor's brand and catalog."}],"related":["Company","Business Entity","Subscription"],"inlineSchemas":[{"name":"OrganizationStatus","schema":"schemas/organization/OrganizationStatus.ts","properties":[{"info":"Current lifecycle state of the organization.","name":"status","type":"enumeration","values":["Draft","Active","Archived"],"required":true},{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"}]}],"inheritance":{"extends":"CoreEntity"}},{"name":"Permission","class":"Dictionary","subsystem":"ACCESS","area":"Security & Permissions","desc":"An atomic capability claim, identified by a structured lowercase code: 'application:scope:action' at resource level (portal:product:create) or 'application:scope:field:action' at field level (portal:product:cost:read). The admin UI displays the action segment in uppercase, but the stored value is lowercase throughout. Grouped into Areas, bundled into Scopes, and assigned to Roles. Permission is a PLATFORM-GLOBAL dictionary — the catalogue of what the software can do, seeded per Application by migration or system process and never authored by a tenant user — so company is null, following the Currency / Locale / Environment precedent. Retired by deactivation (isActive false), never deletion, because every grant that references a permission holds its code as a bare string. code is required and globally unique and is the only value matched by guards, token claims and grants; name is a shared display label and is deliberately not unique. See convention permission-code-format for the full grammar and action vocabulary.","status":"draft","properties":[{"n":"area","r":true,"t":"entityDetail","re":"Area","info":"The Area this permission belongs to — the resource domain used to group permissions in the admin UI and to bundle them into Scopes."},{"k":true,"n":"code","r":true,"t":"string","u":true,"info":"The permission code — required, GLOBALLY unique (redeclared from LookupEntity to tighten the inherited 'unique within scope' to global, since guards and token claims match it with no tenant or application qualifier), and the only value that guards, token claims and Role/Scope grants match on, by exact string equality. STORED ENTIRELY IN LOWERCASE; the uppercase action shown in the admin UI (portal:product:CHANGE-APPROVAL) is a display treatment only and must never be persisted, written back, or compared case-sensitively — lowercase any code copied from a UI surface before storing or matching it. Grammar: 'application:scope:action' for resource-level (portal:product:create, 3 segments) and 'application:scope:field:action' for field-level (portal:product:cost:read, 4 segments); segment count is the discriminator, since multi-word values hyphenate rather than adding a segment. application is an Application.slug; scope is the SINGULAR entity name (product, sales-order — never plural); field is the property gated; action is always the last segment. Actions are either CRUD (exactly create, read, update, delete — never write, never list/view) or a short descriptive operation name (change-status, change-approval, import, discard). Field-level codes carry only read and update, and narrow an operation the holder must already be permitted to perform — they never grant access on their own. See convention permission-code-format for the full rule."},{"n":"description","t":"string","info":"Optional long-form explanation of what the permission allows. Empty on the seeded field-level codes, where the name and code together are self-explanatory."},{"n":"endDate","t":"date","info":"The date on which support for this permission ends — after which it may be deactivated (isActive false) because nothing in code still checks it. Calendar date only (YYYY-MM-DD), no time or timezone, per date-is-calendar-only: a deprecation window is a business commitment measured in days, and a UTC midnight would roll to the previous day in western timezones. Set when isDeprecated is set to true, and null on a permission that is not deprecated. The date is a COMMITMENT TO CONSUMERS, not a scheduler input — nothing deactivates automatically on it, because deactivating a permission whose guards are still live would strip access silently. It marks the earliest date deactivation may be considered, once the code removal is confirmed."},{"n":"isDeprecated","r":true,"t":"boolean","info":"Whether this permission is deprecated: still enforced, still honoured in existing grants, but no longer to be assigned to new Roles and scheduled for removal. Required with a default of false, per boolean-must-be-required — an undefined flag would read as 'not deprecated' and hide the retirement from every consumer. DISTINCT FROM isActive: a deprecated permission is still ACTIVE and still grants access, which is the whole point — it holds the door open while the code that checks it is removed. isActive false is the end state, applied only once nothing enforces the permission any more. The two flags are never set in the same operation."},{"n":"name","r":true,"t":"string","u":false,"info":"Human-readable display label. Redeclared from LookupEntity solely to pin u=false and record WHY, because an earlier revision of this entity wrongly constrained it unique. Sibling codes that belong to one logical grant share a name so the admin UI can present them as a single row — portal:product:create, portal:product:read and portal:product:delete all carry 'Product - CRUD', and the price field pair carries 'Product Price - RU'. Identity lives on code; name is presentation only and must never be used as a lookup key."},{"n":"replacedBy","t":"string","info":"The permission code that supersedes this one — e.g. portal:product:cost:update carries 'portal:product:change-cost'. A plain code string rather than an entityRef, matching how every other grant in the system references a permission and keeping the value readable in migration scripts and admin screens. Optional, because some deprecations genuinely have no successor (a capability removed outright), but a deprecated permission with neither replacedBy nor an explanatory description leaves consumers stranded — the same failure deprecated-needs-replacement guards against for properties. Set alongside isDeprecated and endDate."}],"service":"APR Access","ext":"LookupEntity","shopify":"Access scopes (read_products, write_orders)","notes":"Rebased onto LookupEntity 01 Sep 2026 (delete-and-recreate, since extends is fixed at creation), together with the sibling ACCESS dictionaries Area, Scope and Role. RATIONALE: Permission is class Dictionary, and LookupEntity is the declared base for reference data. The fit is property-for-property — code, name (required, not unique, exactly what Permission needs), isActive for the deactivate-never-delete rule, isReadOnly for records only migration and system may author, and the audit fields the seeded records already carry. CoreEntity was considered and rejected: it provides none of code, name or isActive, so the retirement rule would have had no field to hang on, and its slug would have given Permission a second human-readable key alongside code — the defect no-redundant-entity-id exists to prevent. Environment, already an ACCESS-subsystem Dictionary on LookupEntity, is the direct precedent; Currency and Locale set the platform-global pattern. The former permissionId string was dropped in favour of the inherited UUID id per no-redundant-entity-id.\n\nCOMPANY IS NULL. Permission is platform-global: it is the catalogue of what the software can do, seeded per Application, identical across every tenant. It is therefore exempt from tenant-scoped-dictionary-declares-company alongside Currency, Locale, Country and Environment. Contrast Role, which is tenant-scoped and declares company and application required — Role is where a tenant composes the platform's permissions into its own named bundles within one application.\n\nDISPLAY VS STORED VALUE. The admin permission list renders the action segment uppercase (portal:product:CHANGE-APPROVAL). That string does not exist in the database. Anyone copying a code out of a screen into a guard, seed file, test fixture or ticket is copying a value that will never match. This is the single most likely source of a 'permission looks configured but denies access' bug, because the UI evidence contradicts the runtime behaviour.\n\nTHE APPLICATION SEGMENT IS THE FULL Application.slug (question 42, resolved 01 Sep 2026), selected from the Application list when the permission is created — not typed, and never abbreviated. 'portal' IS the franchise portal's slug; the registry's earlier 'franchise-portal' example was wrong and has been corrected on the Application entity. CONSEQUENCE: the slug is copied in as a bare string with no reference back to Application, so Application.slug is IMMUTABLE once any permission has been seeded against it. Renaming a slug orphans rather than cascades — every code carrying the old value stays a valid string that matches nothing, and every guard checking one silently stops granting. A rename is a full catalogue migration, recorded as an update-time rule on Application.\n\nCODE GRAMMAR inferred from the seeded portal:product:* set (change-approval, change-status, cost:read, cost:update, create, delete, discard, import, price:read, price:update, read) and confirmed with the platform team on six points: singular scope segments, hyphenated multi-word segments, four-verb CRUD, short descriptive operation names, the uppercase action being display-only, and the application segment being the full slug. Enforced by convention permission-code-format.\n\nREMAINING NIT: the code's second segment is called 'scope' in the grammar but is unrelated to the ACCESS Scope entity. The collision is historical and confusing; renaming the grammar segment to 'resource' should be considered.","related":["Area","Scope","Role","Application"],"bv":{"rules":[{"rule":"code is required and globally unique. name is required but must NOT be constrained unique — sibling codes forming one logical grant intentionally share a display name (e.g. 'Product - CRUD'). Lookups resolve on code only.","when":"create","field":"code","severity":"error"},{"rule":"code must be stored entirely in lowercase. The uppercase action segment shown in the admin UI is a display treatment, not the value. Any code received from a UI surface, export or ticket must be lowercased before storage or comparison; an uppercased code must be rejected on write rather than silently normalised, so the source of the bad value is found.","when":"create","field":"code","severity":"error"},{"rule":"Must match the permission-code-format grammar: 'application:scope:action' (3 segments) or 'application:scope:field:action' (4 segments). Colon-delimited, no whitespace, no empty segments. Segment 1 is an Application.slug. Segment 2 is the SINGULAR entity name, hyphenated if multi-word. The final segment is the action, hyphenated if multi-word.","when":"create","field":"code","severity":"error"},{"rule":"OPERATION VS FIELD hardline test. Where a mutation targets a specific field, exactly one shape is correct: if the change posts to a ledger or runs a multi-step or multi-document workflow it is an OPERATION (portal:product:change-price); if it merely sets a stored value on the record it is a FIELD (portal:product:contact-email:update). The test decides, not the UI presentation — a price edit reached through a form field still writes a Price Ledger entry and is therefore an operation.","when":"create","field":"code","severity":"error"},{"rule":"Field-level codes: 'read' is always legitimate, since a read posts to no ledger and can never qualify as an operation. 'update' is legitimate only for plain record edits that survive the hardline test. 'create' and 'delete' are never legitimate at field level — a field is created and destroyed with its parent record.","when":"create","field":"code","severity":"error"},{"rule":"Action segment must be a CRUD verb (create, read, update, delete) or a short descriptive operation name (change-status, change-approval, change-price, change-cost, import, discard). 'write' is not permitted — it conflates create, update and delete. 'list' and 'view' are not permitted — both are 'read'.","when":"create","field":"code","severity":"error"},{"rule":"A code has at most four segments. Field nesting deeper than one level is not supported. Multi-word values within a segment are hyphenated, never split across segments — segment count is what distinguishes resource-level from field-level, so splitting a multi-word resource would make it parse as a field.","when":"create","field":"code","severity":"error"},{"rule":"isReadOnly must be true on every seeded permission. The catalogue is authored by migration and system processes; a tenant user may grant a permission but must never edit or mint one.","when":"create","field":"isReadOnly","severity":"error"},{"rule":"isDeprecated and isActive must never be changed in the same operation. A deprecated permission is still active and still grants access — that is the purpose of the stage. Setting both at once collapses the migration window and turns a planned deprecation into an outage.","when":"update","field":"isDeprecated","severity":"error"},{"rule":"Setting isDeprecated true requires endDate, and requires replacedBy wherever a successor exists. Where the successor is not a one-for-one rename (a field code replaced by an operation, one code split into several), replacedBy names the primary successor and description carries the full mapping — a Role migration cannot be scripted from a bare replacedBy when one old code becomes two.","when":"update","field":"isDeprecated","severity":"error"},{"rule":"endDate is a commitment, not a trigger. Nothing may deactivate a permission automatically when endDate passes; the real gate is confirmed removal of every code path that checks it. A date-driven sweep would strip access from every tenant whose migration ran late, simultaneously and silently.","when":"update","field":"endDate","severity":"error"},{"rule":"Setting isActive false is permitted only after the guards checking this permission have been removed and deployed — remove the guard first, then deactivate, never the reverse. A deactivated permission with a live guard denies access to every holder immediately in production while the UI still shows it as configured; a live permission with a removed guard is inert.","when":"update","field":"isActive","severity":"error"},{"rule":"A Permission record is never deleted. Roles, Scopes and issued tokens reference the code as a bare string with no foreign key, so deletion does not fail, cascade or warn — it silently changes what every existing grant means.","when":"delete","field":"code","severity":"error"}],"lifecycle":{"notes":"Roles may hold the old and new codes simultaneously during migration — the effective grant is their union, so a partially-migrated tenant is never locked out. There is no transition out of retired and no deletion: the record persists so that historic grants remain interpretable.","states":["live","deprecated","retired"],"transitions":[{"to":"deprecated","from":"live","sets":"isDeprecated true, endDate set, replacedBy set where a successor exists; isActive stays TRUE","meaning":"Stop offering the code for new grants. Keep honouring it in every existing Role, Scope and token. Seed the successor in the same migration so there is somewhere to migrate to."},{"to":"retired","from":"deprecated","sets":"isActive false","meaning":"Permitted only once no code path still checks the permission. Confirm by removing the guard and deploying first, then flip isActive."}]},"calculations":[],"crossEntityConstraints":[{"rule":"Permissions may be narrowed by Scope. Effective access is the intersection of scope and role grants, never the union.","entity":"Scope"},{"rule":"A field-level read code unmasks one field within a read the holder must already be permitted to perform. Effective readable fields = (all fields MINUS fields marked restricted) UNION (restricted fields whose code the holder carries). Restriction is marked explicitly on the entity property, not inferred from whether a code exists.","entity":"Role"},{"rule":"The application segment of the code must resolve to a registered Application.slug, and that slug is immutable once any permission references it.","entity":"Application"}]}},{"name":"Price Adjustment","class":"Transactional","subsystem":"CONNECT","area":"Products & Pricing","desc":"Transactional document recording a price change applied to items, reflecting markdowns, markups, or promotional pricing. Writes entries to the Price Ledger. Distinct from option-level priceModifier deltas on OptionValues which are relative and non-transactional.","status":"stub","properties":[{"n":"adjustmentId","r":true,"t":"string"},{"n":"item","r":true,"t":"entityRef","re":"Item"},{"n":"newPrice","r":true,"t":"decimal"},{"n":"oldPrice","r":true,"t":"decimal"},{"n":"priceLevel","t":"entityDetail","re":"Price Level"},{"n":"reason","t":"string"}],"ext":"TransactionalDocument","related":["Item","Price Level","Price Ledger"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Must reference a valid Price Level","entity":"Price Level"},{"rule":"Must reference a valid active Item or Product","entity":"Item"}]}},{"name":"Price Ledger","class":"Ledger","subsystem":"CONNECT","area":"Products & Pricing","desc":"Immutable chronological audit log of all price changes for items across time.","status":"stub","properties":[{"n":"currencyCode","r":true,"t":"string","info":"ISO 4217 alpha-3 code (e.g. 'USD') for price and previousPrice. Flat code, never entityDetail → Currency and never an enumeration — per the ledger-currency-is-iso-string convention. Resolves from the baseCurrency of the Market that owns the Price Level. NO exchangeRate and NO base-currency companion amount: a retail price is SET in the currency of the market it sells in, never converted into it. Each market's price is its own authored value, so converting one market's price into another currency would produce a figure no customer was ever charged. Contrast Cost Ledger, where the vendor genuinely transacts in a foreign currency and conversion to the Company currency is real."},{"n":"item","r":true,"t":"entityRef","re":"Item"},{"c":true,"n":"ledgerLine","r":true,"t":"integer"},{"n":"price","r":true,"t":"decimal"},{"n":"priceLevel","t":"entityDetail","re":"Price Level"}],"ext":"LedgerEntry","related":["Item","Price Level","Price Adjustment"],"bv":{"rules":[{"rule":"Append-only — existing entries must not be modified or deleted","when":"always","field":"LedgerLine","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Price Level","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Named pricing tier or schedule defining different prices for different customer groups, channels, or regions. Extends LookupEntity — no additional fields beyond the base.","status":"stub","properties":[{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. The entity's own validation already requires code to be unique within the Company, which is only expressible if company is required."}],"ext":"LookupEntity","shopify":"Market-specific pricing and B2B catalogs","related":["Market","Product","Item","Price Rule"],"bv":{"rules":[{"rule":"Must be unique within the Company","when":"create","field":"Code","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Should be associated with at least one Market","entity":"Market"}]}},{"name":"Price Rule","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Conditional logic defining when and how prices are calculated or discounts applied.","status":"stub","properties":[{"n":"code","r":true,"t":"string","u":true,"info":"Human-readable key. Unique within scope."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. A pricing rule leaking across the boundary would not merely expose data — it would change what a customer is charged."},{"n":"conditions","t":"schema"},{"n":"name","r":true,"t":"string","u":true},{"n":"priceRuleId","r":true,"t":"string"}],"shopify":"PriceRule resource","related":["Price Level","Promotion","Discount"],"bv":{"rules":[{"rule":"Must resolve deterministically when multiple rules overlap","when":"always","field":"Priority","severity":"warning"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Must reference a valid Price Level","entity":"Price Level"}]}},{"name":"Product","class":"Operational","subsystem":"CONNECT","area":"Products & Pricing","desc":"Master product record (style/SKU group). Defines classification, attributes, options, pricing, vendors, and channel availability. Contains child Items.","status":"draft","properties":[{"n":"approval","t":"schema","info":"Approval workflow state: status (Pending Review / In Review / Approved / Rejected), approvedBy, approvedDate, reason."},{"n":"attributes","r":true,"t":"array","info":"Array of ProductAttribute sub-documents. Each entry references an Attribute and records its selection position on the Product. Required with an empty-array default per array-must-be-required; the Zod schema is nullish today and must be tightened to match (corrected 31 Aug 2026)."},{"n":"brand","t":"entityDetail","re":"Brand"},{"n":"catalogs","r":true,"t":"array","re":"Catalog","info":"Catalogs the product is assigned to. Required with an empty-array default per array-must-be-required (corrected 31 Aug 2026)."},{"n":"class","r":true,"t":"enumeration","v":["Style","Single","Service","Digital","Wallet"],"info":"Determines product behavior. Style = physical, 2+ Items, requires ≥1 Attribute, inventory optionally tracked. Single = physical, 1 Item, Attributes optional, inventory optionally tracked. Service = non-physical, 1 Item, Attributes N/A, inventory cannot be tracked. Digital = non-physical/non-service, 1 Item, inventory optionally tracked. Wallet = stored-value (e.g. gift cards), may be physical, 1 Item, inventory not tracked, creates a liability on sale."},{"n":"classification","r":true,"t":"entityDetail","re":"Classification"},{"n":"code","r":true,"t":"string"},{"n":"countryOfOrigin","t":"entityDetail","re":"Country"},{"n":"defaultBasePrice","t":"decimal","info":"[RESTRICTED:price] Sticker price before price-level overrides. Inherited by child Items unless Item.basePrice is set. Hidden from holders of portal:product:read unless they also carry portal:product:price:read; mutated only via the portal:product:change-price operation, which posts to Price Ledger. See convention restricted-field-marker."},{"n":"defaultDropshipEligibility","t":"enumeration","v":["Available","Required","Unavailable"]},{"n":"defaultMeasurements","t":"schema","info":"Default measurements (height/length/width/weight). Inherited by child Items unless Item.measurements is set. Shape defined by the ProductMeasurements inline schema."},{"n":"defaultPrices","r":true,"t":"array","re":"Price Level","info":"[RESTRICTED:price] Array of ProductPrice objects ({ price: decimal, priceLevel: EntityDetail→Price Level }). One entry per Price Level. Inherited by child Items unless overridden at Item.prices. Required with an empty-array default per array-must-be-required; the Zod schema is nullish today and must be tightened to match (corrected 31 Aug 2026). Hidden from holders of portal:product:read unless they also carry portal:product:price:read; mutated only via the portal:product:change-price operation, which posts to Price Ledger. See convention restricted-field-marker."},{"n":"defaultWeeksOfSupply","t":"integer","info":"Default weeks of supply for this Product, used as a replenishment planning parameter. Inherited by child Items unless Item.weeksOfSupply is set: effectiveValue = Item.weeksOfSupply ?? Product.defaultWeeksOfSupply. This is a LIVE scalar fallback, not a copy taken at Item creation — changing this value re-plans every child Item that has not overridden it, and null at the Item means \"follow the Product\", not \"zero\".\n\nRENAMED from weeksOfSupply on 03 Sep 2026 and added to inheritance.overridableDefaults per overridable-default-prefix; the child override Item.weeksOfSupply then matches under child-override-matches-parent (default{Name} -> {name}).\n\nWHY THE RENAME WAS OVERDUE: the property was documented on both sides as inherited-and-overridable, but appeared in neither Product.inheritance.overridableDefaults nor Item.inheritance.overridableProperties. Both conventions are scoped to those declarations, so neither fired and both entities validated clean against behaviour the registry described but never declared — the same class of gap as the missing ProductVendor.defaultBaseCost closed on 01 Sep 2026.\n\nFOLLOWS defaultBasePrice -> basePrice EXACTLY: a scalar fallback where the child value simply supersedes the parent. It is NOT the ProductVendor.costs -> VendorItemValue.costs shape (a per-Cost-Level merge), which is why the default prefix describes the resolution accurately here and the asymmetry deliberately kept on costs does not apply.\n\nIMPLEMENTATION NOTE: the previous description said Items inherit this value \"on creation\", i.e. copy-on-create. Copy-on-create and live fallback diverge the moment someone edits the Product — under the old reading nothing moves, under this one every non-overridden Item follows. If the shipped implementation copies at Item creation, adopting this resolution is a behaviour change and not merely a rename; verify the replenishment read path before release.\n\nClassification.defaultWeeksOfSupply seeds this value. Whether that level is a create-time seed only or a live third resolution tier (Item ?? Product ?? Classification) is not settled — see the open registry question on Classification default tiers."},{"n":"description","t":"string"},{"c":true,"n":"firstPurchasedAt","t":"datetime","info":"Earliest timestamp this Product appeared on a purchase order across all variants and locations. Named Purchased (not Ordered) to avoid confusion with sales/customer orders. Fold: MIN(locationActivity[].firstPurchasedAt). Cascade: PO subsystem → Item Stock → locationActivity → this scalar. Null if never purchased. Renamed from firstOrderedAt on 2026-07-24."},{"c":true,"n":"firstReceivedAt","t":"datetime","info":"Earliest timestamp this Product was received into any location from an external source (movementType 'Receipt' or 'Return' — transfers post as 'Transfer' and are tracked via firstTransferredAt). Fold: MIN(locationActivity[].firstReceivedAt). Cascade: Stock Ledger → Item Stock → locationActivity → this scalar. Null if never received."},{"c":true,"n":"firstSoldAt","t":"datetime","info":"Earliest timestamp this Product was sold at any location across all variants. Fold: MIN(locationActivity[].firstSoldAt). Cascade: Stock Ledger Service → Item Stock → locationActivity → this scalar. Null if never sold."},{"c":true,"n":"firstTransferredAt","t":"datetime","info":"Earliest timestamp this Product was transferred in or out of any location across all variants. Fold: MIN(locationActivity[].firstTransferredAt). Kept separate from firstReceivedAt so transfer activity is composable in or out of recency analysis. Null if never transferred."},{"n":"hasSerialNumber","r":true,"t":"boolean"},{"n":"hsCode","t":"entityDetail","re":"Hs Code","info":"Harmonized System tariff code for customs and duty classification."},{"n":"items","r":true,"t":"array","re":"Item"},{"c":true,"n":"lastActivityAt","t":"datetime","info":"Most recent activity for this Product across all locations, transfers excluded: MAX(lastPurchasedAt, lastReceivedAt, lastSoldAt). Equivalently MAX(locationActivity[].lastActivityAt). Transfer-inclusive recency = MAX(lastActivityAt, lastTransferredAt) at query time. Recency checks evaluate against a configurable window (e.g. Company.activityRecencyDays). For per-location or per-franchise recency, query locationActivity instead."},{"c":true,"n":"lastPurchasedAt","t":"datetime","info":"Most recent timestamp this Product appeared on a purchase order across all variants and locations. Named Purchased (not Ordered) to avoid confusion with sales/customer orders. Fold: MAX(locationActivity[].lastPurchasedAt). Cascade: PO subsystem → Item Stock → locationActivity → this scalar. Null if never purchased. Renamed from lastOrderedAt on 2026-07-24."},{"c":true,"n":"lastReceivedAt","t":"datetime","info":"Most recent timestamp this Product was received into any location from an external source (movementType 'Receipt' or 'Return' — transfers post as 'Transfer' and are tracked via lastTransferredAt). Fold: MAX(locationActivity[].lastReceivedAt). Cascade: Stock Ledger → Item Stock → locationActivity → this scalar. Null if never received."},{"c":true,"n":"lastSoldAt","t":"datetime","info":"Most recent timestamp this Product was sold at any location across all variants. Fold: MAX(locationActivity[].lastSoldAt). Cascade: Stock Ledger Service → Item Stock → locationActivity → this scalar. Null if never sold."},{"c":true,"n":"lastTransferredAt","t":"datetime","info":"Most recent timestamp this Product was transferred in or out of any location across all variants. Fold: MAX(locationActivity[].lastTransferredAt). Kept separate from lastReceivedAt so transfer activity is composable in or out of recency analysis. Null if never transferred."},{"c":true,"n":"locationActivity","r":true,"t":"array","re":"Location","info":"Array of ProductLocationActivity inline schema objects — per-location lifecycle/activity dates at Product × Location grain. Required; defaults to [] at creation, entries created lazily on first activity at a Location (array size bounded by touched locations). Each entry aggregates the child Items' locationActivity entries for that Location (MIN for first*, MAX for last*) — immediate source is Item.locationActivity, not Item Stock, so the product/item domain stays independent of the inventory projection; Item Stock reconciles against the same values. location unique within the array. Enables per-location and per-franchise recency: resolve Franchise Group → Locations, then match entries with lastActivityAt (or MAX(lastActivityAt, lastTransferredAt) for transfer-inclusive) >= NOW() - recency window. The Product-level all-location scalars are folds (MIN/MAX) over this array."},{"n":"media","r":true,"t":"array","info":"Array of ProductMedia inline schema objects. Each entry references a Media asset (image, thumbnail or video) associated with the product. Required with an empty-array default per array-must-be-required (corrected 31 Aug 2026). Entries must reference Media with mediaType in (Image, Video) and purpose='ProductMedia' — see the Media cross-entity constraint."},{"n":"menus","r":true,"t":"array","re":"Product Menu"},{"n":"name","r":true,"t":"string"},{"n":"options","r":true,"t":"array","re":"Option","info":"Configurable Options available on this Product. Required with an empty-array default per array-must-be-required; the Zod schema is nullish today and must be tightened to match (corrected 31 Aug 2026)."},{"n":"preferredVendors","r":true,"t":"array","info":"Array of ProductPreferredVendor inline schema objects. One entry per Franchise Group, designating the Vendor that group prefers to source this Product from. May differ from ProductVendor.isPrimary (the tenant-wide primary). Referenced Vendor must already exist in Product.vendors[]. At most one preferred vendor per Franchise Group. Required with an empty-array default per array-must-be-required. THE SOLE HOME FOR THIS DESIGNATION — the matching Item.preferredVendors was removed on 31 Aug 2026 because preferred vendor is about who is buying (franchise group, territory, distributor agreement) while the variant is about what is bought; the two are orthogonal and the designation never varied by Item. It is therefore NOT an overridable default. RESOLUTION: take the entry for the buying Franchise Group; if that Vendor has no Item.vendors[] entry, inherit this Product's cost for it; if that entry is isDiscontinued, fall back to the isPrimary vendor and surface the gap rather than substituting silently."},{"n":"productGroups","r":true,"t":"array","re":"Product Group","info":"Product Groups this Product belongs to. Required with an empty-array default per array-must-be-required; the Zod schema is nullish today and must be tightened to match (corrected 31 Aug 2026)."},{"n":"productNo","r":true,"t":"string"},{"n":"salesChannels","r":true,"t":"array","re":"Sales Channel"},{"n":"seasons","r":true,"t":"array","re":"Season","info":"One or more merchandising seasons, with a primary flag. Required with an empty-array default per array-must-be-required; the Zod schema is nullish today and must be tightened to match (corrected 31 Aug 2026)."},{"n":"shortName","t":"string"},{"n":"slug","t":"string","u":true,"info":"URL-friendly identifier for storefront routing and public API lookups. Equivalent to Shopify's handle property. Auto-generated from product name if not provided. Must be unique across all products."},{"n":"status","r":true,"t":"schema","info":"Lifecycle status of the product. Tracks current state and who/when it was last changed."},{"n":"stockLimitGroups","r":true,"t":"array","re":"Stock Limit Group","info":"Stock Limit Groups governing min/max thresholds for this Product. Required with an empty-array default per array-must-be-required; the Zod schema is nullish today and must be tightened to match (corrected 31 Aug 2026)."},{"n":"taxClass","r":true,"t":"entityDetail","re":"Tax Class"},{"n":"trackInventory","r":true,"t":"boolean","info":"Whether inventory quantities are tracked for this product. Optional for Style, Single, and Digital. Must be false for Service and Wallet products."},{"n":"type","t":"entityDetail","re":"Product Type"},{"c":true,"n":"vendorActivity","r":true,"t":"array","re":"Vendor","info":"Array of ProductVendorActivity inline schema objects — per-vendor activity dates at Product × Vendor grain (purchase/receipt legs only). Required; defaults to []; entries created lazily on first activity with a vendor. vendor unique within the array. Aggregates child Items' vendorActivity entries (MIN/MAX per vendor). Deliberately separate from vendors[] (current sourcing config): entries are NEVER deleted on vendor unassignment, preserving historical activity across vendor changes. Amount fields to be added by the deferred amounts request (registry question #11)."},{"n":"vendors","r":true,"t":"array","info":"Array of ProductVendor inline schema objects. Each entry links a Vendor to the product with optional primary designation and cost-level pricing. Product-level cost baseline per Vendor × Cost Level, inherited by Items unless overridden at VendorItemValues."}],"ext":"OperationalDocument","shopify":"Product resource","notes":"Vendor model tightened 31 Aug 2026 on the principle that Product.vendors owns the sourcing RELATIONSHIP and Item.vendors owns only what varies per SKU.\n\npreferredVendors moved out of inheritance.overridableDefaults and into nonOverridableProperties. Item.preferredVendors was removed entirely: preferred vendor is a per-Franchise-Group designation about who is buying, while the variant is about what is bought, and those are orthogonal. This is now the sole home for the designation.\n\nThat change alone cleared both remaining registry errors — overridable-default-prefix here and child-override-matches-parent on Item — without the rename to defaultPreferredVendors that would otherwise have been forced. The rename would have been the wrong fix: it entrenches an inheritance relationship that does not exist.\n\nProductVendor.isPrimary changed from optional to required, and is now the only place a primary vendor is designated (VendorItemValue.isPrimary was removed). It is load-bearing as the sourcing fallback when a franchise group's preferred vendor has discontinued a variant.\n\nNote the containment constraint on preferredVendors — the referenced Vendor must already be in vendors[] — is still enforced by validation rather than by structure. Folding the per-franchise designation into ProductVendor itself would make the invalid state unrepresentable. Logged as a registry question rather than done here, because it rewrites an inline schema and inverts the natural authoring UI.\n\nWEEKS OF SUPPLY BROUGHT INTO THE DECLARED INHERITANCE MODEL, 03 Sep 2026. weeksOfSupply renamed to defaultWeeksOfSupply and added to inheritance.overridableDefaults; Item.weeksOfSupply added to Item.inheritance.overridableProperties. Resolution is now stated as a live scalar fallback: effectiveValue = Item.weeksOfSupply ?? Product.defaultWeeksOfSupply, the same shape as defaultBasePrice -> basePrice.\n\nThis was a declaration gap, not a naming preference. Both property descriptions already claimed the value was inherited by Items and overridable at the Item level, but the property was listed in neither inheritance block. Because overridable-default-prefix and child-override-matches-parent are both scoped to those lists, neither convention fired and both entities validated clean against behaviour that was documented in prose and declared nowhere — the same class of defect as the missing ProductVendor.defaultBaseCost closed on 01 Sep 2026. Contrast the preferredVendors case above: there the conventions were satisfied by REMOVING an inheritance relationship that did not exist; here they are satisfied by DECLARING one that does.\n\nNote the semantic change riding along with the rename: the old description said Items inherit the value \"on creation\" (copy-on-create), which diverges from live fallback the moment a Product is edited. Live fallback is the intended behaviour — the point of setting weeks of supply at style level is that changing it re-plans the children — but the replenishment read path should be verified before release in case it currently copies.\n\nClassification.defaultWeeksOfSupply seeds this value; whether that level is a create-time seed or a live third tier is deliberately NOT assumed here and is logged as registry question #48, alongside the identical ambiguity on Classification.defaultTaxClass.","related":["Item","Classification","Tax Class","Season","Brand","Vendor","Sales Channel","Product Menu","Product Group","Stock Limit Group","Catalog","Price Level","Franchise Group","Location"],"bv":{"rules":[{"rule":"Must be unique across the Company","when":"create","field":"ProductNo","severity":"error"},{"rule":"Must be unique across the Company if provided","when":"always","field":"ProductCode","severity":"error"},{"rule":"The combination of attributeValues across Items must be unique within the Product — no two Items may share the same attribute value set","when":"always","field":"items","severity":"error"},{"rule":"Cannot add an Item until at least one Vendor is selected. For Style products, at least one Attribute must also be selected.","when":"item-create","field":"items","severity":"error"},{"rule":"Cannot deselect or change an Attribute if at least one child Item has a value for that Attribute","when":"update","field":"attributes","severity":"error"},{"rule":"Style products must have 2 or more Items","when":"always","field":"class","severity":"error"},{"rule":"Single, Service, Digital, and Wallet products can have at most 1 Item","when":"always","field":"class","severity":"error"},{"rule":"Attributes are not relevant to Service products","when":"always","field":"class","severity":"info"},{"rule":"Service and Wallet products cannot have trackInventory set to true","when":"always","field":"trackInventory","severity":"error"},{"rule":"Wallet products create a stored value / liability on sale","when":"always","field":"class","severity":"info"},{"rule":"vendor must be unique within vendors — at most one entry per Vendor","when":"always","field":"vendors","severity":"error"},{"rule":"Exactly one vendors[] entry should have isPrimary = true. The primary vendor is designated here and nowhere else — Items never restate it","when":"always","field":"vendors","severity":"error"},{"rule":"Each preferredVendors[].vendor must reference a Vendor that is present in Product.vendors[].vendor","when":"always","field":"preferredVendors","severity":"error"},{"rule":"franchiseGroup must be unique within preferredVendors — at most one preferred vendor per Franchise Group","when":"always","field":"preferredVendors","severity":"error"},{"rule":"Preferred vendor is designated at Product level only and is NOT inherited as an overridable default — Items carry no preferredVendors. Resolution for a buying Franchise Group: take this Product's entry for that group; if the Vendor has no Item.vendors[] entry, inherit the Product cost; if that entry is isDiscontinued, fall back to the isPrimary vendor and surface the gap","when":"read","field":"preferredVendors","severity":"info"},{"rule":"location must be unique within locationActivity — at most one activity entry per Location","when":"always","field":"locationActivity","severity":"error"},{"rule":"vendor must be unique within vendorActivity — at most one activity entry per Vendor. Entries are never deleted on vendor unassignment; vendorActivity[].vendor need not be present in vendors[]","when":"always","field":"vendorActivity","severity":"error"}],"lifecycle":{"states":["Draft","Active","Archived"],"disallowed":[{"to":"Draft","from":"Active","reason":"Products cannot revert to Draft once activated"},{"to":"Draft","from":"Archived","reason":"Products cannot revert to Draft once activated"}],"transitions":[{"to":"Active","from":"Draft","conditions":["At least one Item exists","Base price is set"]},{"to":"Archived","from":"Active","conditions":["No open Sales Orders referencing active Items","Total qty on hand across all Items is zero"]},{"to":"Active","from":"Archived","conditions":[]}],"initialState":"Draft","terminalExits":["Draft"]},"calculations":[{"name":"itemCount","formula":"COUNT(items)","trigger":"query-time"},{"name":"vendorCount","formula":"COUNT(DISTINCT vendor via Item.vendors)","trigger":"query-time"},{"name":"salesChannelCount","formula":"COUNT(salesChannels)","trigger":"query-time"},{"name":"optionCount","formula":"COUNT(options)","trigger":"query-time"},{"name":"attributeCount","formula":"COUNT(attributes)","trigger":"query-time"},{"info":"Whether this Product has recent activity (purchased on a PO, externally received, or sold) at a given Location, transfers excluded. Window is configurable (e.g. Company.activityRecencyDays, default 90). Evaluated at query time against the stored locationActivity timestamps — recency is never stored as a boolean because it goes stale as the clock moves with no triggering event.","name":"hasRecentActivityAtLocation(location, window)","formula":"EXISTS locationActivity entry WHERE location = $location AND lastActivityAt >= NOW() - $window","trigger":"query-time"},{"info":"Transfer-inclusive variant: same as hasRecentActivityAtLocation but also counts Transfer movements as activity.","name":"hasRecentActivityAtLocation(location, window, includeTransfers=true)","formula":"EXISTS locationActivity entry WHERE location = $location AND MAX(lastActivityAt, lastTransferredAt) >= NOW() - $window","trigger":"query-time"},{"info":"Whether this Product has recent activity at any Location belonging to a Franchise Group. Resolves Franchise Group → member Locations at query time (via Location.groups) — franchiseGroup is deliberately NOT denormalized into locationActivity because group membership can change. Pass includeTransfers to compose transfers in.","name":"hasRecentActivityForFranchiseGroup(franchiseGroup, window, includeTransfers)","formula":"EXISTS locationActivity entry WHERE location IN (Locations of $franchiseGroup) AND (includeTransfers ? MAX(lastActivityAt, lastTransferredAt) : lastActivityAt) >= NOW() - $window","trigger":"query-time"},{"info":"Per-location activity dates. Immediate source is the child Items' locationActivity entries (MIN for first*, MAX for last* per Location) — not Item Stock, so the product/item domain stays independent of the inventory projection; Item Stock reconciles against the same values. Entries created lazily.","name":"locationActivity","formula":"per Location: MIN/MAX(Item.locationActivity[location].*) across the Product's child Items","trigger":"derived: propagated when any child Item's locationActivity entry changes"},{"info":"Per-vendor activity dates (purchase/receipt legs only). Immediate source is the child Items' vendorActivity entries (MIN/MAX per Vendor). Entries keyed by vendor, created lazily, NEVER deleted on vendor unassignment — vendors[] stays pure sourcing config while history survives vendor changes.","name":"vendorActivity","formula":"per Vendor: MIN/MAX(Item.vendorActivity[vendor].*) across the Product's child Items","trigger":"derived: propagated when any child Item's vendorActivity entry changes"}],"crossEntityConstraints":[{"rule":"Cannot archive Product if any child Items are Active","when":"archive","entity":"Item"},{"rule":"Cannot archive Product if total qty on hand across child Items is greater than zero","when":"archive","entity":"Item Stock"},{"rule":"Item.vendors is a strict subset of this Product's vendors[] — the lists must not diverge. Unlinking a Vendor here must cascade to remove or invalidate the corresponding Item.vendors entries","when":"always","entity":"Item"},{"rule":"Items carry no preferredVendors. The preferred vendor for a Franchise Group resolves from this Product's preferredVendors for every child Item","when":"always","entity":"Item"},{"rule":"Must reference a valid Classification hierarchy","when":"always","entity":"Classification"},{"rule":"preferredVendors[].vendor must resolve to an Active Vendor within the Company","when":"always","entity":"Vendor"},{"rule":"locationActivity[].location must reference a valid Location","when":"always","entity":"Location"},{"rule":"locationActivity entries aggregate the child Items' locationActivity entries per Location (MIN for first*, MAX for last*); Product-level scalars are MIN/MAX folds over locationActivity","when":"always","entity":"Item"},{"rule":"vendorActivity[].vendor must reference a valid Vendor (need not be currently assigned in vendors[] — entries survive unassignment)","when":"always","entity":"Vendor"},{"rule":"vendorActivity entries aggregate the child Items' vendorActivity entries per Vendor (MIN for first*, MAX for last*)","when":"always","entity":"Item"}]},"inlineSchemas":[{"name":"VendorCost","schema":"schemas/product-common/VendorCost.ts","properties":[{"info":"[RESTRICTED:cost] The cost amount at this Cost Level. THIS IS WHERE THE NUMBER ACTUALLY LIVES — VendorCost is shared by ProductVendor.costs and VendorItemValue.costs, so marking it here covers both the Product baseline and the per-variant override in one place. Its sibling costLevel is deliberately NOT marked: which cost levels a vendor is priced at is sourcing structure, not a cost figure. Hidden from holders of portal:product:read unless they also carry portal:product:cost:read. See convention restricted-field-marker.","name":"cost","type":"decimal","required":true},{"name":"costLevel","type":"entityDetail","required":true,"relatedEntity":"Cost Level"}]},{"name":"ProductMedia","schema":"schemas/product/ProductMedia.ts","properties":[{"info":"Product image assets.","name":"images","type":"array","relatedEntity":"Media"},{"info":"Thumbnail image assets.","name":"thumbnails","type":"array","relatedEntity":"Media"},{"info":"Video assets.","name":"videos","type":"array","relatedEntity":"Media"}]},{"name":"ProductPrice","schema":"schemas/product-common/ProductPrice.ts","properties":[{"name":"price","type":"decimal","required":true},{"name":"priceLevel","type":"entityDetail","required":true,"relatedEntity":"Price Level"}]},{"name":"ProductSeason","schema":"schemas/product/ProductSeason.ts","properties":[{"name":"isPrimary","type":"boolean","required":true},{"name":"seasons","type":"array<entityDetail>","required":true,"relatedEntity":"Season"}]},{"name":"ProductStatus","schema":"schemas/product/ProductStatus.ts","properties":[{"info":"Current lifecycle state of the product.","name":"status","type":"enumeration","values":["Draft","Active","Archived"],"required":true},{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"}]},{"name":"ProductVendor","info":"Links a Vendor to the Product and holds the Product-level cost baseline. This is where the sourcing RELATIONSHIP lives — which vendors supply this product and which one is primary. Per-variant commercial values (vendor SKU, barcodes, cost overrides, pack size, unit of measure, dropship, per-vendor discontinuation) belong on Item.vendors (VendorItemValue), which is a strict subset of this list and must not diverge from it.","schema":"schemas/product/ProductVendor.ts","properties":[{"info":"Reference to the Vendor entity. Unique within vendors.","name":"vendor","type":"entityDetail","required":true,"relatedEntity":"Vendor"},{"info":"[RESTRICTED:cost] Cost per Cost Level for this vendor. The baseline, inherited by Items unless overridden at VendorItemValue.costs.\n\nNAME KEPT DELIBERATELY, 01 Sep 2026. Its sibling defaultBaseCost carries the default prefix and its child override VendorItemValue.baseCost drops it, per overridable-default-prefix and child-override-matches-parent. This property does NOT follow that pattern: parent and child are both called costs. The asymmetry is a considered choice, not an oversight — renaming to defaultCosts would break an existing field for a naming nicety, and the two relationships are not the same kind anyway. defaultBaseCost -> baseCost is a scalar fallback (effectiveValue = child ?? parent). costs -> costs is a per-Cost-Level merge, where a child entry supersedes the parent entry for THAT level and levels absent from the child still resolve to the parent. A prefix implying simple replacement would misdescribe it. Note the conventions do not fire here regardless: they are scoped to entity-level inheritance.overridableDefaults, not to inline-schema properties.\n\nMARKED AT THE LEAF, NOT THE CONTAINER: Product.vendors itself is unrestricted, so a cost-restricted user still sees WHO supplies this product and which vendor is primary — only what they charge is masked. Hidden from holders of portal:product:read unless they also carry portal:product:cost:read; mutated only via the portal:product:change-cost operation, which posts to Cost Ledger. See convention restricted-field-marker.","name":"costs","type":"array<VendorCost>"},{"info":"Whether this is the primary/default vendor for the Product. THE ONLY PLACE primary vendor is designated — the matching VendorItemValue.isPrimary was removed on 31 Aug 2026 because primary vendor is a relationship fact that never varies by variant, and restating it per Item created a second source of truth that could silently disagree with this one. Exactly one entry should be primary. Changed from optional to required on 31 Aug 2026 per boolean-must-be-required, and because it is now load-bearing as the sourcing fallback.","name":"isPrimary","type":"boolean","required":true},{"info":"[RESTRICTED:cost] The Product-level base cost from this vendor, before cost-level overrides. Inherited by child Items unless VendorItemValue.baseCost is set: effectiveValue = VendorItemValue.baseCost ?? ProductVendor.defaultBaseCost. The exact cost-side counterpart of Product.defaultBasePrice, one level deeper because cost is per-vendor while price is not.\n\nADDED 01 Sep 2026 to close a real gap. VendorItemValue.baseCost was documented as overriding 'the Product-level ProductVendor cost baseline', but no such property existed — ProductVendor carried only costs (per Cost Level). It could not have been folded into that array either: VendorCost requires costLevel, so no entry can represent a cost that precedes cost-level assignment. The consequence was that an Item with no baseCost fell back to nothing rather than to a Product baseline, and any cost resolution written against the documented behaviour was resolving against a property that was not there.\n\nNamed with the default prefix per overridable-default-prefix, so the child override VendorItemValue.baseCost matches it under child-override-matches-parent (default{Name} -> {name}). Hidden from holders of portal:product:read unless they also carry portal:product:cost:read; mutated only via the portal:product:change-cost operation, which posts to Cost Ledger. See convention restricted-field-marker.","name":"defaultBaseCost","type":"decimal"}]},{"name":"AttributeValue","schema":"schemas/product-common/AttributeValue.ts","properties":[{"info":"Reference to the parent Attribute dictionary entity this value belongs to.","name":"attribute","type":"entityDetail","required":true,"relatedEntity":"Attribute"},{"name":"code","type":"string","required":true},{"name":"name","type":"string"},{"info":"Ordinal position of this value within the parent Attribute (e.g. 1 = first colour, 2 = second colour).","name":"sequence","type":"integer"},{"name":"aliases","type":"array<string>"}]},{"name":"ProductApproval","schema":"schemas/product/ProductApproval.ts","properties":[{"name":"status","type":"enumeration","values":["Pending Review","In Review","Approved","Rejected"],"required":true},{"name":"approvedBy","type":"string"},{"name":"approvedDate","type":"datetime"},{"name":"reason","type":"string"}]},{"name":"ProductAttribute","schema":"schemas/product/ProductAttribute.ts","properties":[{"info":"Reference to the selected Attribute dictionary entity (e.g. Color, Size).","name":"attribute","type":"entityDetail","required":true,"relatedEntity":"Attribute"},{"info":"The ordinal position of this attribute on the product (e.g. 1 = Attribute 1, 2 = Attribute 2).","name":"position","type":"integer","required":true}]},{"name":"ProductMeasurements","schema":"schemas/product/ProductMeasurements.ts","properties":[{"name":"height","type":"string"},{"name":"length","type":"string"},{"name":"weight","type":"string"},{"name":"width","type":"string"}]},{"name":"ProductSalesChannel","schema":"schemas/product/ProductSalesChannel.ts","properties":[{"name":"code","type":"string","required":true},{"name":"isPublished","type":"boolean","required":true},{"name":"name","type":"string"}]},{"name":"ProductVendorActivity","info":"Per-vendor lifecycle/activity dates for this Product (Product × Vendor grain). Aggregates the child Items' vendorActivity entries for that Vendor (MIN for first*, MAX for last*). Activity projection keyed by vendor — deliberately separate from vendors[] (ProductVendor, current sourcing config): entries are NEVER deleted when a vendor is unassigned, so historical activity survives vendor changes. Entries created lazily on first activity with a vendor; vendor unique within the array. Only the purchase/receipt legs exist — sales and transfers have no vendor dimension. Amount fields scoped to the deferred amounts request — see registry question #11.","schema":"schemas/product/ProductVendorActivity.ts","extends":"OperationalSubDocument","properties":[{"info":"The Vendor this activity entry applies to. Unique within vendorActivity. Need NOT be present in vendors[] — entries survive vendor unassignment.","name":"vendor","type":"entityDetail","required":true,"relatedEntity":"Vendor"},{"info":"Earliest PO line placed for any of the Product's Items with this Vendor. MIN(Item.vendorActivity[].firstPurchasedAt) across child Items.","name":"firstPurchasedAt","type":"datetime","calculated":true},{"info":"Most recent PO line placed for any of the Product's Items with this Vendor. MAX(Item.vendorActivity[].lastPurchasedAt) across child Items.","name":"lastPurchasedAt","type":"datetime","calculated":true},{"info":"Earliest external receipt (movementType Receipt or Return) of any of the Product's Items sourced from this Vendor. MIN(Item.vendorActivity[].firstReceivedAt).","name":"firstReceivedAt","type":"datetime","calculated":true},{"info":"Most recent external receipt (movementType Receipt or Return) of any of the Product's Items sourced from this Vendor. MAX(Item.vendorActivity[].lastReceivedAt).","name":"lastReceivedAt","type":"datetime","calculated":true}]},{"name":"ProductPreferredVendor","info":"Per-franchise-group preferred vendor override for this Product. Each entry maps one Franchise Group to the Vendor that group prefers to source this Product from, which may differ from ProductVendor.isPrimary (the tenant-wide primary). The referenced Vendor MUST already exist in Product.vendors[].vendor — preferred vendor cannot be an un-linked vendor. At most one entry per Franchise Group (unique constraint on franchiseGroup within Product.preferredVendors).","schema":"schemas/product/ProductPreferredVendor.ts","extends":"OperationalSubDocument","properties":[{"info":"The Franchise Group this preferred vendor applies to.","name":"franchiseGroup","type":"entityDetail","required":true,"relatedEntity":"Franchise Group"},{"info":"The Vendor this Franchise Group prefers to source this Product from. Must reference a Vendor that is present in Product.vendors[].vendor.","name":"vendor","type":"entityDetail","required":true,"relatedEntity":"Vendor"}]},{"name":"ProductLocationActivity","info":"Per-location lifecycle/activity dates for this Product (Product × Location grain). One entry per Location with any activity — entries are created lazily on first activity, so array size is bounded by touched locations, not the full location list. All datetime fields are computed MIN/MAX aggregations of Item Stock rows across the Product's Items at this Location, maintained by the same events that update Item Stock (InventoryPositionChanged from the Stock Ledger; PO line events from CONNECT). Movement categorization uses the Stock Ledger movementType enum (Sale, Receipt, Adjustment, Transfer, Return); Adjustments do not count as activity. location is unique within the array. Franchise-level recency resolves at query time: Franchise Group → member Locations → $elemMatch(location IN [...], lastActivityAt >= cutoff). Do NOT denormalize franchiseGroup into entries — location group membership can change and would silently stale product documents.","schema":"schemas/product/ProductLocationActivity.ts","extends":"OperationalSubDocument","properties":[{"info":"The Location this activity entry applies to. Unique within locationActivity.","name":"location","type":"entityDetail","required":true,"relatedEntity":"Location"},{"info":"Earliest PO line placed for any of the Product's Items with deliverTo = this location. MIN(Item Stock.firstPurchasedAt) across the Product's Items at this Location. Named Purchased (not Ordered) to avoid confusion with sales/customer orders.","name":"firstPurchasedAt","type":"datetime","calculated":true},{"info":"Most recent PO line placed for any of the Product's Items with deliverTo = this location. MAX(Item Stock.lastPurchasedAt) across the Product's Items at this Location.","name":"lastPurchasedAt","type":"datetime","calculated":true},{"info":"Earliest external receipt (movementType Receipt or Return — transfers post as Transfer and are tracked separately) of any of the Product's Items at this Location. MIN(Item Stock.firstReceivedAt).","name":"firstReceivedAt","type":"datetime","calculated":true},{"info":"Most recent external receipt (movementType Receipt or Return — transfers post as Transfer and are tracked separately) of any of the Product's Items at this Location. MAX(Item Stock.lastReceivedAt).","name":"lastReceivedAt","type":"datetime","calculated":true},{"info":"Earliest sale (movementType Sale — a sale is a sale regardless of channel; Sales Channel is an attribute of the sale) of any of the Product's Items at this Location. MIN(Item Stock.firstSoldAt).","name":"firstSoldAt","type":"datetime","calculated":true},{"info":"Most recent sale (movementType Sale — a sale is a sale regardless of channel; Sales Channel is an attribute of the sale) of any of the Product's Items at this Location. MAX(Item Stock.lastSoldAt).","name":"lastSoldAt","type":"datetime","calculated":true},{"info":"Earliest Transfer movement (paired entries; direction via qty sign; both directions count) of any of the Product's Items at this Location. MIN(Item Stock.firstTransferredAt). Kept separate so transfer activity is composable in or out of recency.","name":"firstTransferredAt","type":"datetime","calculated":true},{"info":"Most recent Transfer movement (paired entries; direction via qty sign; both directions count) of any of the Product's Items at this Location. MAX(Item Stock.lastTransferredAt). Kept separate so transfer activity is composable in or out of recency.","name":"lastTransferredAt","type":"datetime","calculated":true},{"info":"Most recent activity for this Product at this Location, transfers excluded: MAX(lastPurchasedAt, lastReceivedAt, lastSoldAt). The indexed key for per-location and per-franchise recency filtering. Transfer-inclusive recency = MAX(lastActivityAt, lastTransferredAt) at query time.","name":"lastActivityAt","type":"datetime","calculated":true}]}],"inheritance":{"childEntity":"Item","overridableDefaults":["defaultBasePrice","defaultDropshipEligibility","defaultMeasurements","defaultPrices","defaultWeeksOfSupply"],"nonOverridableProperties":["brand","class","classification","countryOfOrigin","hasSerialNumber","hsCode","menus","options","preferredVendors","productGroups","salesChannels","seasons","stockLimitGroups","taxClass","trackInventory","type"]}},{"name":"Product Category","class":"Taxonomy","subsystem":"CONNECT","area":"Products & Pricing","desc":"Hierarchical product classification tree. Supports unlimited nesting depth for organizing products into browsable categories. Products can belong to multiple categories. Used for storefront navigation, reporting rollups, and merchandising rules.","status":"draft","properties":[{"n":"categoryAttributes","r":true,"t":"array","info":"Attributes associated with this category. When a product is assigned to this category, these attributes are suggested or required on the product. Enables attribute inheritance from taxonomy to product. Required with an empty-array default per array-must-be-required (corrected 31 Aug 2026) — most categories carry no attributes of their own, so the empty array is the common case rather than an edge case."},{"n":"image","t":"entityRef","re":"Media","info":"Optional category image for storefront display."},{"n":"slug","t":"string","u":true,"info":"URL-friendly identifier for storefront routing. Auto-generated from name if not provided."}],"ext":"TaxonomyEntity","notes":"Category values follow the Google Product Taxonomy standard. This ensures consistent, industry-recognized classification across sales channels and simplifies feed generation for Google Shopping, Meta, and other marketplace integrations.","related":["Sales Channel","Media","Attribute"]},{"name":"Product Group","class":"Operational","subsystem":"CONNECT","area":"Products & Pricing","desc":"Logical grouping of products for merchandising, promotions, channel publishing, or reporting. Maps to Shopify Collection. Can be Manual (hand-picked products) or Smart (rule-driven automatic membership).","status":"draft","properties":[{"n":"code","r":true,"t":"string","u":true,"info":"URL-friendly identifier. Maps to Shopify handle. Used as EntityDetail.code when referenced from Product."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. Load-bearing for Smart groups in particular: a rule-driven membership query that is not tenant-filtered would pull another tenant's products into this one's collection."},{"n":"description","t":"string","info":"Rich text or HTML description of the group. Maps to Shopify body_html."},{"n":"disjunctive","r":true,"t":"boolean","info":"Rule matching logic: true = product matches ANY rule (OR), false = product must match ALL rules (AND). Only applicable when Type = Smart."},{"n":"displayOrder","t":"enumeration","v":["Manual","AlphaAsc","AlphaDesc","BestSelling","CreatedAsc","CreatedDesc","PriceAsc","PriceDesc"],"info":"Controls product display ordering within the group. Maps directly to Shopify sort_order."},{"n":"media","t":"schema","info":"ProductGroupMedia inline schema — images, thumbnails, and videos referencing the Media entity."},{"n":"name","r":true,"t":"string","info":"Display name. Maps to Shopify Collection title."},{"n":"products","r":true,"t":"array","re":"Product","info":"Explicit product membership (Manual type) or computed result (Smart type)."},{"n":"rules","t":"schema","info":"Smart collection rules. Array of { column, relation, condition } objects. Only applicable when Type = Smart. Maps to Shopify SmartCollection rules."},{"n":"salesChannels","r":true,"t":"array","re":"Sales Channel","info":"Which channels this group is published to. Maps to Shopify publication/published_scope."},{"n":"seo","t":"schema","info":"SEO title and description overrides for search engines. Maps to Shopify Collection SEO fields."},{"n":"sequence","t":"integer","info":"Display ordering among groups. Aligns with EntityDetail.sequence pattern on Product."},{"n":"status","r":true,"t":"schema","info":"Lifecycle status of the product group. Tracks current state and who/when it was last changed."},{"n":"type","r":true,"t":"enumeration","v":["Manual","Smart"],"info":"Manual = hand-picked product list. Smart = rule-driven automatic membership. Maps to Shopify CustomCollection vs SmartCollection."}],"shopify":"Collection resource (CustomCollection + SmartCollection)","related":["Product","Sales Channel","Promotion","Product Menu"],"bv":{"rules":["Rules array is required when Type = Smart, ignored when Type = Manual","Disjunctive is only applicable when Type = Smart","Products array is manually managed when Type = Manual, computed when Type = Smart"],"lifecycle":"Draft → Active → Archived","calculations":[{"name":"productCount","formula":"COUNT(products)","trigger":"query-time"},{"name":"salesChannelCount","formula":"COUNT(salesChannels)","trigger":"query-time"}],"crossEntityConstraints":[]},"inlineSchemas":[{"name":"ProductGroupMedia","properties":[{"n":"images","t":"array","re":"Media","info":"Product group image assets."},{"n":"thumbnails","t":"array","re":"Media","info":"Thumbnail image assets."},{"n":"videos","t":"array","re":"Media","info":"Video assets."}]},{"name":"ProductGroupStatus","schema":"schemas/product-group/ProductGroupStatus.ts","properties":[{"info":"Draft = unpublished, Active = published and visible, Archived = hidden/retired.","name":"status","type":"enumeration","values":["Draft","Active","Archived"],"required":true},{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"}]}]},{"name":"Product Menu","class":"Operational","subsystem":"CONNECT","area":"Products & Pricing","desc":"Curated product assortment or navigation structure for specific contexts (in-store menus, kiosk displays).","status":"stub","properties":[{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it."},{"n":"menuId","r":true,"t":"string"},{"n":"name","r":true,"t":"string"},{"n":"productGroups","r":true,"t":"array","re":"Product Group"},{"n":"products","r":true,"t":"array","re":"Product"},{"n":"salesChannels","r":true,"t":"array","re":"Sales Channel"}],"related":["Product","Product Group","Sales Channel"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Product Type","class":"Taxonomy","subsystem":"CONNECT","area":"Products & Pricing","desc":"Hierarchical product type classification (e.g. Physical → Apparel, Digital → Subscription). Determines processing rules for inventory, fulfillment, and taxation. Supports unlimited nesting depth.","status":"draft","properties":[],"ext":"TaxonomyEntity","related":["Product"]},{"name":"Promotion","class":"Operational","subsystem":"CONNECT","area":"Promotions","desc":"A marketing campaign applying one or more Discounts under defined eligibility rules.","status":"stub","properties":[{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it."},{"n":"discounts","r":true,"t":"array","re":"Discount"},{"n":"eligibility","t":"schema"},{"n":"endDate","t":"datetime"},{"n":"name","r":true,"t":"string"},{"n":"promotionId","r":true,"t":"string"},{"n":"startDate","t":"datetime"}],"shopify":"DiscountAutomaticNode / DiscountCodeNode","related":["Discount","Coupon","Sales Order"],"bv":{"rules":[{"rule":"StartDate must be before EndDate","when":"always","field":"DateRange","severity":"error"}],"lifecycle":{"states":["Draft","Scheduled","Active","Expired","Archived"],"transitions":[{"to":"Scheduled","from":"Draft","conditions":["Valid date range and at least one rule defined"]},{"to":"Active","from":"Scheduled","conditions":["Start date reached"]},{"to":"Expired","from":"Active","conditions":["End date passed"]},{"to":"Archived","from":"Active","conditions":[]}],"initialState":"Draft"},"calculations":[],"crossEntityConstraints":[{"rule":"May reference one or more Discount definitions","entity":"Discount"}]}},{"name":"Purchase","class":"Transactional","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"System-generated settlement record produced when the three-way match (Purchase Order + Goods Receipt + Vendor Invoice) reconciles successfully. Not independently created — represents the completed financial fact of a procurement cycle.","status":"stub","properties":[{"n":"amount","r":true,"t":"decimal","info":"Reconciled purchase amount from the matched invoice"},{"n":"date","r":true,"t":"datetime","info":"Date the three-way match was reconciled"},{"n":"goodsReceipt","r":true,"t":"entityRef","re":"Goods Receipt"},{"c":true,"n":"purchaseId","r":true,"t":"string","info":"System-generated identifier"},{"n":"purchaseOrder","r":true,"t":"entityRef","re":"Purchase Order"},{"n":"vendor","r":true,"t":"entityRef","re":"Vendor"},{"n":"vendorInvoice","r":true,"t":"entityRef","re":"Vendor Invoice"}],"ext":"TransactionalDocument","notes":"Buy-side counterpart to Sale. Automatically generated upon successful three-way match and payment reconciliation.","related":["Vendor","Purchase Order","Goods Receipt","Vendor Invoice"],"bv":{"rules":[{"rule":"System-generated — cannot be manually created or edited","when":"always","field":"PurchaseId","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Must reference a fully or partially received PO","entity":"Purchase Order"},{"rule":"Must reference a posted Goods Receipt linked to the PO","entity":"Goods Receipt"},{"rule":"Must reference a matched and approved Vendor Invoice for the same PO","entity":"Vendor Invoice"}]}},{"name":"Purchase Order","class":"Order","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"Authorization to buy goods from a Vendor. Header discounts/fees for invoice matching; line discounts/fees affect item cost. The type property governs how ordered quantity maps to destinations: Single and Allocated ship everything to defaultShipToLocation, Multi lets each line name its own, and Drop Ship sends goods from the vendor straight to a customer address.","status":"draft","properties":[{"n":"actualArrivalDate","t":"datetime","info":"Actual arrival date at the ship-to location. Populated when the Goods Receipt is posted."},{"n":"actualShipDate","t":"datetime","info":"Actual ship date from the vendor's facility. Populated from the Advanced Shipping Notice or Goods Receipt."},{"n":"buyer","t":"entityRef","re":"Employee","info":"The purchasing agent responsible for this order. References an Employee whose Functions includes 'Buyer'."},{"n":"cancelIfNotShippedByDate","t":"datetime","info":"If goods have not shipped by this date, the order may be automatically cancelled per vendor agreement."},{"n":"cancelReason","t":"string","info":"Reason the purchase order was cancelled, if applicable."},{"n":"currency","t":"entityDetail","re":"Currency","info":"Order currency. Defaults from Vendor.defaultCurrency."},{"n":"defaultCostLevel","t":"entityDetail","re":"Cost Level","info":"Header-level default cost level for the purchase order. New lines default their unitCost from the vendor's unit cost at this cost level; can be overridden per line without changing the vendor's unit cost. Defaults from the Vendor / Market defaultCostLevel on creation. Parallels Market.defaultCostLevel and Cost Ledger.costLevel. Company-scoped, so modeled as entityDetail per the tenant-isolation convention."},{"n":"defaultShipToLocation","r":true,"t":"entityDetail","re":"Location","info":"Receiving location for the order, and the value every line's shipToLocation inherits on create. For Single, Allocated and Drop Ship every line must equal it; only Multi may override per line. For Drop Ship goods never physically arrive here — they ship from the vendor to dropShipAddress — so it identifies the owning/ordering location for accounting and receives no inventory. Renamed from shipToLocation on 31 Aug 2026 when the type enum made the default-vs-override relationship explicit."},{"c":true,"n":"discountAmount","t":"decimal","info":"Calculated rollup. SUM(discounts[].amount) — the total of the header-level discount entries, not an independently editable figure. Fixed entries hold constant as lines change; Percent entries re-resolve against DiscountBasis on every line change. Must not exceed DiscountBasis. Below-the-line: used for invoice matching only, does not affect item cost. Feeds TotalDiscountAmount."},{"n":"discounts","r":true,"t":"array","info":"Array of PurchaseOrderDiscount sub-documents. Header-level discounts applied to the purchase order. Below-the-line — used for invoice matching only, does not affect item cost. Summed into the calculated discountAmount."},{"n":"dropShipAddress","t":"valueType","vt":"Address","info":"Address value type. A STATIC SNAPSHOT of the address the vendor was told to ship to, copied at ordering time rather than referenced from the Customer's address book. Required when type = 'Drop Ship' and must be null for every other type. Deliberately a copy, for two reasons: a customer may delete or edit the ship-to address they used, and a reference would then dangle or silently rewrite history; and this is the address actually given to a third party, so it is a record of what was committed to, not a view of current customer data. Paired with dropShipCustomer, which carries the identity this snapshot deliberately does not."},{"n":"dropShipCustomer","t":"entityRef","re":"Customer","info":"The Customer the goods are being drop shipped to. Required when type = 'Drop Ship' and null otherwise. Paired with dropShipAddress and neither replaces the other: this answers WHO the order was for and survives as a live link for service, returns and history; dropShipAddress answers WHERE it was actually sent and must not move. A Customer may hold many ship-to addresses, so the reference alone cannot identify the destination — and pointing at one entry in the customer's address book would dangle the moment they delete it. Added 31 Aug 2026."},{"n":"expectedArrivalDate","t":"datetime","info":"Expected arrival date at the ship-to location. Defaults from vendor leadTimeDays + orderDate."},{"n":"expectedShipDate","t":"datetime","info":"Expected ship date from the vendor's facility."},{"n":"fees","r":true,"t":"array","info":"Array of PurchaseOrderFee sub-documents. Header-level fees (freight, handling, duties). Below-the-line — used for invoice matching only."},{"n":"isArchived","r":true,"t":"boolean","info":"Soft archive flag. Archived POs are excluded from active views."},{"n":"lines","r":true,"t":"array","info":"Array of PurchaseOrderLine sub-documents. Each line specifies an Item, quantity, cost and a required shipToLocation inherited from defaultShipToLocation. How many lines an Item may occupy follows from one invariant rather than from the type: the pair (item, shipToLocation) is unique across lines. For Single, Allocated and Drop Ship all lines share the same destination, so that reduces to one line per Item; for Multi it permits an Item once per destination."},{"n":"orderDate","r":true,"t":"datetime","info":"Date the purchase order was placed."},{"n":"paymentTerms","t":"entityDetail","re":"Vendor Payment Term","info":"Payment terms for this order. Defaults from the Vendor's defaultPaymentTerms for the applicable franchise group."},{"n":"purchaseOrderNo","r":true,"t":"string","u":true,"info":"Human-readable identifier for the purchase order. Required and unique within Company, per entity-no-property convention. Corrected from integer to string and declared unique on 31 Aug 2026 — every other {entity}No in the registry is a string, and PO numbers routinely carry prefixes, franchise or location segments, and leading zeros, none of which survive an integer."},{"n":"referenceNo","t":"string","info":"External reference number for this order — typically the vendor's own reference or confirmation number, but may equally be a buyer's, buying group's, or upstream system's reference. Free text, not unique. Renamed from vendorReferenceNo on 22 Sep 2026 because the reference does not always originate with the vendor; matches Vendor.referenceNo."},{"n":"status","r":true,"t":"schema","info":"PurchaseOrderStatus inline schema capturing current document status."},{"n":"type","r":true,"t":"enumeration","v":["Single","Multi","Allocated","Drop Ship"],"info":"Classification of the purchase order by how ordered quantity maps to destinations. Every line always carries a shipToLocation, inherited from defaultShipToLocation; the type governs whether a line may override it and what else the line must carry. SINGLE — every line's shipToLocation equals defaultShipToLocation. MULTI — lines may override with any active Location; the same Item may appear on several lines provided each names a different destination, and each distinct destination produces at least one Shipment of its own. ALLOCATED — every line's shipToLocation equals defaultShipToLocation and the goods arrive as one delivery, but each line carries an allocations array of location and quantity recording the intended onward distribution. DROP SHIP — every line's shipToLocation equals defaultShipToLocation (the owning location for accounting) and the vendor ships directly to the additionally-required dropShipAddress; goods never physically reach a Location. Replaced the previous Standard / Blanket / Drop Ship / Special Order set on 31 Aug 2026."},{"n":"vendor","r":true,"t":"entityRef","re":"Vendor"}],"ext":"OperationalDocument","shopify":"Shopify PO (Stocky)","notes":"Header Discounts/Fees are below-the-line (invoice matching only). Line Discounts/Fees affect item cost.\n\nTYPE SEMANTICS defined by the product owner 31 Aug 2026, replacing Standard / Blanket / Drop Ship / Special Order. Every line carries its own required shipToLocation, inherited from defaultShipToLocation when the line is created:\n- SINGLE — every line's shipToLocation equals defaultShipToLocation.\n- MULTI — lines may override with any active Location. The same Item may appear on several lines provided each names a different destination; three locations for one Item means three lines and at least three Shipments. This is the only type permitted to override.\n- ALLOCATED — every line's shipToLocation equals defaultShipToLocation and goods arrive as ONE delivery, but each line carries an allocations array of location + qty recording the onward distribution.\n- DROP SHIP — every line's shipToLocation equals defaultShipToLocation, and the vendor ships directly to the additionally-required dropShipAddress.\n\nDROP SHIP AND defaultShipToLocation, confirmed 31 Aug 2026: it REMAINS REQUIRED for Drop Ship. Goods never physically arrive there, so it is the owning/ordering location for accounting rather than a receiving one, and no inventory posts against it. Making it conditionally required was considered and rejected — that would leave a Drop Ship PO with no location at all, and therefore nothing to attribute the spend, the buyer or the franchise-group scope to.\n\nALLOCATION MAY BE PARTIAL, confirmed 31 Aug 2026. SUM(allocations[].qty) must not EXCEED the line's qty, but it may be less, at submission and at every other stage. Whatever is not allocated stays at defaultShipToLocation as on-hand stock in that location's stock ledger and is never transferred out. Over-allocation is the only error, because it would instruct the movement of units the order never bought. This makes Allocated a partial-distribution instruction rather than an all-or-nothing one: a distribution centre can order 100, push 80 to stores and deliberately retain 20 as backstock, expressed on a single line. A brief earlier draft of this entity required allocations to be complete at submission; that was wrong and has been removed, along with the Draft-to-Open condition and the qty-edit guard that depended on it.\n\nThe QuantityRetained calculation (QuantityOrdered - QuantityAllocated) exists to name that remainder as a legitimate business figure rather than a defect. Raising a line's qty is therefore always safe — it just increases the retained portion. Reducing it below the allocated total is still refused, since that would over-allocate.\n\nWHY LINES INHERIT RATHER THAN LEAVING shipToLocation NULL: an earlier draft left it null for three of the four types, so every consumer had to fall back to the header and branch on type to work out where a line was going. Populating it on create makes the read path uniform — Goods Receipt, Shipment and allocation logic all read line.shipToLocation unconditionally.\n\nThe change also collapsed four rules into one. Duplicate-line behaviour is now a single invariant — THE PAIR (item, shipToLocation) IS UNIQUE ACROSS LINES — which for Single, Allocated and Drop Ship reduces to one line per Item automatically, because all their lines share a destination, and for Multi permits an Item once per destination.\n\nTwo consequences of inheritance that needed their own rules: changing defaultShipToLocation cascades to lines for Single/Allocated/Drop Ship but must NOT touch Multi lines, which are explicit; and switching a Multi PO to another type resets line destinations to the default, which can collide two lines on the same (item, shipToLocation) pair and must be refused rather than silently merged. Switching TO Allocated leaves every line unallocated, which is valid — the whole receipt simply stays at defaultShipToLocation until someone enters a distribution.\n\nThe distinction between Multi and Allocated is worth stating plainly because the two look similar and are not: Multi actually ships separately per location and therefore multiplies lines and shipments; Allocated ships once and records intent, so it multiplies neither.\n\nSTILL INFERRED, NOT STATED — worth confirming: there is no link from a Drop Ship PO to the Customer or Sales Order it fulfils. Only a raw address is modelled, which cannot answer 'which order was this for', cannot survive a customer changing their address, and gives the vendor no reference to quote back. Now that defaultShipToLocation is confirmed as the accounting anchor rather than a destination, this is the remaining gap in the Drop Ship shape.\n\npurchaseOrderNo is a string, required and unique within Company. Corrected from integer 31 Aug 2026.\n\nORDER-LEVEL (HEADER) DISCOUNT, confirmed 22 Sep 2026. Kept as the discounts[] array of PurchaseOrderDiscount rather than flattened to header fields, so one or many below-the-line discounts are handled with the same shape. Each entry is entered either as an amount (discountType = 'Fixed') or a percent (discountType = 'Percent'):\n- FIXED — amount is authored and never changes as lines are added, edited or removed.\n- PERCENT — percent is the retained input, stored as entered (10 = 10%); amount is calculated as ROUND(percent / 100 × DiscountBasis) to the currency's precision, and re-resolved on every line change. basisAmount snapshots the basis used, for audit and invoice matching.\n- DiscountBasis = SUM over lines of ((qty − qtyCancelled) × unitCost − line discountAmount). Net of cancelled quantity, decided 22 Sep 2026, so cancelled units no longer earn header discount. Deliberately separate from TotalLines, which still uses ordered qty.\n- Multiple Percent entries apply INDEPENDENTLY to DiscountBasis and do not compound (10% + 5% = 15%).\n- discountAmount = SUM(discounts[].amount) and must not exceed DiscountBasis.\n- NAMING, 22 Sep 2026, per property-naming-convention-standard decision A (Percent, not Percentage): property percentage → percent, enum value 'Percentage' → 'Percent', and calc DiscountPercentage → effectiveDiscountPercent.\nOPEN: no rule yet freezes percent re-resolution once lines are no longer editable (e.g. after receipt); currently it re-resolves on any line change.\n\nDISCOUNT REASONS AND LINE DISCOUNTS[], confirmed by the product owner 22 Sep 2026.\n- PurchaseOrderDiscount gains discountReason → Purchasing Discount, the discount-side counterpart to PurchaseOrderFee.feeType → Purchasing Fee. It records WHY a discount was given so discounts can be reported, matched and GL-mapped by reason. description remains as a free-text note. discountReason is optional for now so existing entries stay valid.\n- The reason must permit the level it is used at (appliesToHeader / appliesToLine). It never decides cost treatment — the level does. Purchasing Fee flags renamed to the same appliesToHeader / appliesToLine pair (were isGlobalFee / isItemFee), and fee types are now validated against their level the same way.\n- Early-payment / settlement discounts are governed by Vendor Payment Term and are NOT entered as discounts[] entries.\n- PurchaseOrderLine gains discounts[], reusing the same PurchaseOrderDiscount shape as the header (exactly as line fees[] reuse PurchaseOrderFee). Line discountAmount is now a calculated rollup SUM(line discounts[].amount), no longer authored.\n- Percent line entries resolve against LineDiscountBasis = (qty − qtyCancelled) × unitCost — net of cancelled quantity, matching the header basis — and apply independently, never compounding. Line discountAmount must not exceed LineDiscountBasis.\n- Resolution order on any line change: re-resolve that line's Percent entries → recompute line discountAmount and extCost → recompute DiscountBasis → re-resolve Percent header entries.\n- basisAmount is level-dependent: DiscountBasis for header entries, LineDiscountBasis for line entries.\nOPEN: whether discountReason should become required once reason dictionaries are seeded per Company. OPEN: Vendor Invoice, Bill and Vendor Credit are described as paralleling the PO discount structure and should adopt discountReason and line discounts[] too.","related":["Vendor","Employee","Goods Receipt","Purchase","Vendor Invoice","Advanced Shipping Notice","Location","Vendor Payment Term","Purchasing Fee","Purchasing Discount","Shipment","Item","Sales Order","Transfer Order","Customer"],"bv":{"rules":[{"rule":"Must have at least one line item before submitting","when":"submit","field":"Lines","severity":"error"},{"rule":"Defaults from latest vendor unit cost for the item × cost level when a line is added; can be overridden per PO line without changing the vendor's unit cost","when":"create","field":"LineUnitCost","severity":"info"},{"rule":"Only Style, Single, and Service products can be added to a Purchase Order","when":"line-add","field":"lines","severity":"error"},{"rule":"Must be an active Location that can receive inventory","when":"submit","field":"DefaultShipToLocation","severity":"error"},{"rule":"Defaults from Vendor orderCurrency on creation; cannot be changed after lines exist","when":"edit","field":"currency","severity":"error"},{"rule":"THE DESTINATION INVARIANT — the pair (item, shipToLocation) must be unique across lines, for every PO type. Because Single, Allocated and Drop Ship require every line to share defaultShipToLocation, this reduces to one line per Item for those three without needing a rule of its own; for Multi it permits an Item once per destination","when":"always","field":"lines","severity":"error"},{"rule":"Every line's shipToLocation is required and is populated from defaultShipToLocation when the line is created. Consumers read it unconditionally and never fall back to the header","when":"line-add","field":"lines","severity":"error"},{"rule":"TYPE Single — every line's shipToLocation must equal defaultShipToLocation, and allocations must be empty","when":"always","field":"type","severity":"error"},{"rule":"TYPE Multi — lines may override shipToLocation with any active Location in the Company, and allocations must be empty. This is the only type permitted to override","when":"always","field":"type","severity":"error"},{"rule":"TYPE Allocated — every line's shipToLocation must equal defaultShipToLocation and the goods arrive as one delivery; each line's allocations record the intended onward distribution, which may cover part or all of the line","when":"always","field":"type","severity":"error"},{"rule":"TYPE Drop Ship — every line's shipToLocation must equal defaultShipToLocation, allocations must be empty, BOTH dropShipCustomer and dropShipAddress are required, and every line must name the Sales Order line it fulfils. Goods never physically reach a Location, so no inventory is received against defaultShipToLocation","when":"always","field":"type","severity":"error"},{"rule":"dropShipCustomer and dropShipAddress are both null unless type = 'Drop Ship', and both required when it is. Neither substitutes for the other — the customer carries live identity for service, returns and history; the address carries what was actually sent","when":"always","field":"DropShipAddress","severity":"error"},{"rule":"dropShipAddress is a SNAPSHOT copied at ordering time and must never be re-resolved from the customer's current address book. A customer editing or deleting the address they used must not change what this order recorded, because that address was already given to a third party","when":"update","field":"DropShipAddress","severity":"error"},{"rule":"dropShipCustomer should agree with the linked Sales Order's shipCustomer, or with the line-level shipToCustomer where the sales order line sets one. A divergence means the PO is shipping to someone other than who the order says","when":"always","field":"DropShipCustomer","severity":"error"},{"rule":"dropShipAddress should equal the linked Sales Order line's shipToAddress at the moment the PO is created. Both are snapshots taken from the same source, so they should agree on creation and both should stay frozen afterwards","when":"create","field":"DropShipAddress","severity":"warning"},{"rule":"salesOrder and salesOrderLineId are set together or neither is set. A reference to a Sales Order without the specific line is not resolvable","when":"always","field":"lines","severity":"error"},{"rule":"THE LINK MUST BE MUTUAL — if a PurchaseOrderLine names a SalesOrderLine, that SalesOrderLine must name this PurchaseOrderLine back. The reference is stored on both sides so each document answers its own question without scanning the other, which means nothing but this rule keeps them agreeing; a one-sided link is a defect, not a partial state","when":"always","field":"lines","severity":"error"},{"rule":"The Sales Order link is REQUIRED on every line when type = 'Drop Ship' and OPTIONAL otherwise — the optional case covers a special order bought in for a customer to collect in store, which is the same relationship without the direct shipment","when":"always","field":"lines","severity":"error"},{"rule":"A linked Sales Order line's isDropShip must be true when this PO's type is 'Drop Ship', and false otherwise. The two documents express the same fact at different grains — per-line on the sales side, per-document here — and must not disagree","when":"always","field":"lines","severity":"error"},{"rule":"Cancelling a line that names a SalesOrderLine leaves that sales order line with nothing fulfilling it. The linked line must be surfaced for re-sourcing rather than left silently unfulfilled — on a Drop Ship PO this is the customer's entire order line","when":"cancel","field":"lines","severity":"error"},{"rule":"Changing a line's item or qty on a Drop Ship PO diverges it from the Sales Order line it fulfils. Such an edit must be refused or must carry the change through to the linked line","when":"update","field":"lines","severity":"error"},{"rule":"Changing defaultShipToLocation cascades to every line for Single, Allocated and Drop Ship, since their lines must equal it. For Multi it does NOT cascade — those lines are explicit destinations and changing the header default must leave them alone","when":"update","field":"DefaultShipToLocation","severity":"error"},{"rule":"SUM(allocations[].qty) must not EXCEED the line's qty. Under-allocation is permitted at every stage including submission — the unallocated remainder stays at defaultShipToLocation as on-hand stock and is never transferred out. Over-allocation is the only error, because it would instruct the movement of units the order never bought","when":"always","field":"allocations","severity":"error"},{"rule":"Every allocations[].qty must be greater than zero, and location must be unique within a line's allocations array","when":"always","field":"allocations","severity":"error"},{"rule":"Reducing a line's qty below SUM(allocations[].qty) must be refused until allocations are reduced first, otherwise the allocation silently exceeds the order. Raising a line's qty is always safe — it simply increases the remainder retained at defaultShipToLocation","when":"update","field":"qty","severity":"error"},{"rule":"Changing type on an existing PO must revalidate every line. Multi to any other type must reset line shipToLocations to defaultShipToLocation, which can collide two lines on the same (item, shipToLocation) pair and must be refused rather than silently merged. Allocated to any other type strands allocations; any other type to Allocated leaves every line unallocated, which is valid. Changing AWAY from Drop Ship leaves the Sales Order links in place, which is correct — the goods are still for that order, they are simply no longer shipping direct — but dropShipCustomer and dropShipAddress must be cleared","when":"update","field":"type","severity":"error"},{"rule":"Not directly editable — it is the sum of discounts[].amount. Adjusting the header discount means adding, editing or removing a discounts[] entry","when":"update","field":"DiscountAmount","severity":"error"},{"rule":"When discountType = 'Percent': percent is REQUIRED, must be > 0 and ≤ 100 (stored as entered, 10 = 10%), and amount and basisAmount are calculated and not user-editable. When discountType = 'Fixed': percent and basisAmount must be NULL, and amount is authored and must be > 0. Applies to header discounts[] and line discounts[] alike","when":"always","field":"Discounts","severity":"error"},{"rule":"A Percent-type header entry resolves against DiscountBasis — SUM over lines of ((qty − qtyCancelled) × unitCost − line discountAmount) — storing the basis in basisAmount and the result in amount, rounded to the PO currency's precision. amount is authoritative for the rollup; percent is the retained input","when":"always","field":"Discounts","severity":"error"},{"rule":"Multiple Percent-type entries are applied INDEPENDENTLY, each against the full basis for their level, and never compound — 10% + 5% yields 15% of the basis. Applies to header and line discounts alike","when":"always","field":"Discounts","severity":"error"},{"rule":"Any change to lines — add, remove, qty, qtyCancelled, unitCost or line discounts — must re-resolve basisAmount and amount on every Percent-type header discounts[] entry. Fixed-type entries are never recomputed. Without this a header percent discount silently goes stale as lines change","when":"line-change","field":"Discounts","severity":"error"},{"rule":"discountAmount must not exceed DiscountBasis — the header discount cannot drive the order below zero. Evaluated on discount change and on line change, since removing or cancelling lines can shrink the basis under a Fixed entry","when":"always","field":"DiscountAmount","severity":"error"},{"rule":"LINE DISCOUNTS — line discountAmount is not directly editable; it is SUM(line discounts[].amount). Adjusting a line discount means adding, editing or removing an entry in that line's discounts[] array","when":"update","field":"lines","severity":"error"},{"rule":"A Percent-type LINE entry resolves against LineDiscountBasis = (qty − qtyCancelled) × unitCost for that line, storing the basis in basisAmount and the result in amount, rounded to the PO currency's precision. Changing that line's qty, qtyCancelled or unitCost must re-resolve every Percent-type entry on the line, then cascade to Percent-type header entries via DiscountBasis","when":"line-change","field":"lines","severity":"error"},{"rule":"A line's discountAmount must not exceed its LineDiscountBasis — a line discount cannot drive extCost below zero. Evaluated on line discount change and on qty, qtyCancelled or unitCost change, since shrinking the basis can leave a Fixed entry too large","when":"always","field":"lines","severity":"error"},{"rule":"Every discounts[] entry's discountReason, when set, must reference an active Purchasing Discount that permits its level — appliesToHeader = true for header entries, appliesToLine = true for line entries","when":"always","field":"Discounts","severity":"error"},{"rule":"Every fees[] entry's feeType, when set, must reference an active Purchasing Fee that permits its level — appliesToHeader = true for header fees, appliesToLine = true for line fees","when":"always","field":"Fees","severity":"error"}],"lifecycle":{"states":["Draft","Open","PartiallyReceived","Complete","Cancelled"],"transitions":[{"to":"Open","from":"Draft","conditions":["At least one line item exists","Vendor is Active","defaultShipToLocation is valid","Every line has a shipToLocation permitted by the PO type","dropShipCustomer and dropShipAddress are both present when type = 'Drop Ship'","Every line names a Sales Order line when type = 'Drop Ship'","For type Allocated, no line is over-allocated"]},{"to":"Cancelled","from":"Draft","conditions":[]},{"to":"PartiallyReceived","from":"Open","conditions":["At least one line has received qty > 0"]},{"to":"Complete","from":"Open","conditions":["All lines fully received"]},{"to":"Cancelled","from":"Open","conditions":["No Goods Receipts posted against this PO","Linked Sales Order lines surfaced for re-sourcing"]},{"to":"Complete","from":"PartiallyReceived","conditions":["All lines fully received or cancelled"]}],"initialState":"Draft"},"calculations":[{"name":"AmountOrdered","formula":"Sum of line (qty × unitCost)","trigger":"On line add/edit/remove"},{"name":"AmountReceived","formula":"Sum of line receivedQty × unitCost","trigger":"On receipt posting"},{"name":"AmountDue","formula":"AmountOrdered - AmountReceived","trigger":"On receipt posting"},{"name":"TotalLines","formula":"Sum of line extCost","trigger":"On line add/edit/remove"},{"name":"TotalFees","formula":"Sum of header fee amounts + sum of all line fee amounts","trigger":"On fee change"},{"info":"The base that Percent-type LINE discounts resolve against. Net of cancelled quantity so a cancelled unit no longer earns discount, matching the header DiscountBasis treatment.","name":"PurchaseOrderLine.LineDiscountBasis","formula":"(qty − qtyCancelled) × unitCost","trigger":"On line qty/qtyCancelled/unitCost change"},{"info":"Line-level discount rollup, calculated rather than independently editable. Affects item cost. Must not exceed LineDiscountBasis.","name":"PurchaseOrderLine.discountAmount","formula":"SUM(line discounts[].amount)","trigger":"On line discount change AND on line qty/qtyCancelled/unitCost change"},{"name":"PurchaseOrderLine.extCost","formula":"qty × unitCost − discountAmount","trigger":"On line change"},{"info":"The base that Percent-type header discounts resolve against. Net of cancelled quantity so a cancelled unit no longer earns discount, and net of line-level discounts so the header percent applies to what is actually being paid for. Distinct from TotalLines, which uses ordered qty.","name":"DiscountBasis","formula":"SUM(lines[]: (qty − qtyCancelled) × unitCost − discountAmount)","trigger":"On line add/edit/remove/cancel"},{"info":"Snapshot of the basis each Percent entry was resolved against, retained for audit and invoice matching. Level-dependent: DiscountBasis for header entries, the line's LineDiscountBasis for line entries. NULL for Fixed entries.","name":"PurchaseOrderDiscount.basisAmount","formula":"discountType = 'Percent' ? (header entry ? DiscountBasis : LineDiscountBasis) : NULL","trigger":"On discount change AND on line add/edit/remove/cancel"},{"info":"Percent-type discounts are resolved to a monetary amount at write time so each rollup can sum both types uniformly. Each Percent entry applies independently to the basis for its level — entries never compound. Rounded to the PO currency's precision. Fixed-type entries are authored and never recomputed.","name":"PurchaseOrderDiscount.amount","formula":"discountType = 'Fixed' ? authored value : ROUND(percent / 100 × basisAmount, currency precision)","trigger":"On discount change AND on line add/edit/remove/cancel"},{"info":"Header-level discount rollup, calculated rather than independently editable. Must not exceed DiscountBasis.","name":"discountAmount","formula":"SUM(discounts[].amount)","trigger":"On discount change AND on line add/edit/remove/cancel"},{"info":"Header rollup plus line-level discounts. The two behave differently downstream: header discounts are below-the-line and affect invoice matching only, while line discounts affect item cost and flow into extCost and the Cost Ledger.","name":"TotalDiscountAmount","formula":"discountAmount + sum of line discountAmounts","trigger":"On discount change AND on line add/edit/remove"},{"name":"TotalAmount","formula":"TotalLines + TotalFees - TotalDiscountAmount","trigger":"On line or fee change"},{"info":"Effective overall discount percent combining header and line discounts, expressed as entered-style percent (10 = 10%). Renamed from DiscountPercentage on 22 Sep 2026 per property-naming-convention-standard decision A, and to avoid confusion with the user-entered PurchaseOrderDiscount.percent.","name":"effectiveDiscountPercent","formula":"(TotalDiscountAmount / TotalLines) × 100","trigger":"On discount change"},{"name":"QuantityOrdered","formula":"Sum of line quantities","trigger":"On line change"},{"name":"QuantityReceived","formula":"Sum of line received quantities","trigger":"On receipt posting"},{"name":"QuantityDue","formula":"QuantityOrdered - QuantityReceived","trigger":"On receipt posting"},{"info":"Total allocated onward across the order. Only meaningful for type = 'Allocated'; zero otherwise.","name":"QuantityAllocated","formula":"SUM(lines[].allocations[].qty)","trigger":"On allocation change"},{"info":"The portion of an Allocated order that stays at defaultShipToLocation rather than moving onward. A legitimate business figure, not an error signal — a distribution centre ordering 100, pushing 80 to stores and keeping 20 as backstock has QuantityRetained = 20. Zero for every other type.","name":"QuantityRetained","formula":"QuantityOrdered - QuantityAllocated","trigger":"On allocation or line change"},{"info":"Number of distinct destinations the order resolves to. Yields 1 for Single and Drop Ship without special-casing. For Multi it is the minimum number of Shipments the order will produce. Allocated counts allocation targets, describing onward destinations rather than shipments.","name":"destinationCount","formula":"type = 'Allocated' ? COUNT(DISTINCT lines[].allocations[].location) : COUNT(DISTINCT lines[].shipToLocation)","trigger":"query-time"},{"info":"Distinct Sales Orders this PO is buying for. Normally 1 on a Drop Ship PO, since a single dropShipAddress implies a single customer destination — a value above 1 is worth surfacing, as it means one shipment address is serving several orders.","name":"salesOrderCount","formula":"COUNT(DISTINCT lines[].salesOrder WHERE NOT NULL)","trigger":"query-time"},{"name":"lineCount","formula":"COUNT(lines)","trigger":"query-time"},{"name":"receiptCount","formula":"COUNT(Goods Receipt WHERE purchaseOrder = this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Must reference a valid active Vendor","entity":"Vendor"},{"rule":"defaultShipToLocation must be a valid active Location with receiving capability. For type Drop Ship it is the owning location for accounting and does not receive inventory","entity":"Location"},{"rule":"Every line's shipToLocation, and every allocations[].location on an Allocated PO, must be a valid active Location within the same Company","entity":"Location"},{"rule":"A line may name the Sales Order line it fulfils — required for every line on a Drop Ship PO, optional elsewhere for special orders bought in for collection. The reference is line-to-line, stored on both sides, and must agree in both directions. The linked line's isDropShip must match this PO's type","entity":"Sales Order"},{"rule":"dropShipCustomer is a live reference used for service, returns and history. dropShipAddress is a frozen copy of where the goods were actually sent, and must NOT be a reference into the Customer's address book — that would dangle when the customer deletes the address and silently rewrite history when they edit it. Per committed-value-stored-as-snapshot-with-reference","entity":"Customer"},{"rule":"A type Multi PO produces at least one Shipment per distinct line shipToLocation, and may produce several to the same location. A type Allocated PO produces shipments to defaultShipToLocation only — onward distribution to the allocated locations is a separate movement","entity":"Shipment"},{"rule":"Receiving creates Goods Receipt documents, reconciled against lines by their own shipToLocation rather than the header — uniform across types because every line carries one. For type Drop Ship no inventory-bearing receipt is created at any Location","entity":"Goods Receipt"},{"rule":"On an Allocated PO the full received quantity posts to defaultShipToLocation's stock ledger first. The allocated portion then moves onward as Transfer Orders generated from the allocations; the unallocated remainder simply stays, with no transfer generated and no exception raised","entity":"Transfer Order"},{"rule":"Invoice matching validates against PO lines and receipts. Header discounts and fees are the below-the-line figures reconciled here; they never reach item cost. Discounts may additionally be matched by discountReason","entity":"Vendor Invoice"},{"rule":"If specified, must be valid for the selected Vendor. Early-payment and settlement discounts are governed here, not recorded as Purchasing Discount entries","entity":"Vendor Payment Term"},{"rule":"An Item may occupy one line per distinct shipToLocation — one line per Item for Single, Allocated and Drop Ship, and one per destination for Multi","entity":"Item"},{"rule":"Every discounts[] entry's discountReason must reference an active Purchasing Discount in the same Company that permits the entry's level (appliesToHeader for header, appliesToLine for line)","entity":"Purchasing Discount"},{"rule":"Every fees[] entry's feeType must reference an active Purchasing Fee in the same Company that permits the entry's level (appliesToHeader for header, appliesToLine for line)","entity":"Purchasing Fee"}]},"inlineSchemas":[{"name":"PurchaseOrderFee","extends":"OperationalSubDocument","properties":[{"info":"Fee amount.","name":"amount","type":"decimal","required":true},{"info":"Description of this fee.","name":"description","type":"string"},{"info":"Reference to the Purchasing Fee type.","name":"feeType","type":"entityDetail","relatedEntity":"Purchasing Fee"}]},{"name":"PurchaseOrderLine","info":"One Item, quantity and cost on a purchase order. Every line carries its own shipToLocation, inherited from the header defaultShipToLocation on create and overridable only when the PO type is Multi. Because the destination is always populated, downstream consumers — Goods Receipt, Shipment, allocation — read line.shipToLocation unconditionally and never branch on PO type. A line may additionally point back at the Sales Order line it fulfils, which is required for Drop Ship and available to any customer-ordered line.","extends":"OperationalSubDocument","properties":[{"info":"Array of PurchaseOrderLineAllocation sub-documents recording the intended per-Location distribution of this line's quantity after it arrives. Populated only when the PO type is 'Allocated'; must be empty for Single, Multi and Drop Ship. SUM(allocations[].qty) must not exceed qty; any shortfall stays at defaultShipToLocation.","name":"allocations","type":"array"},{"info":"Calculated rollup. SUM(discounts[].amount) for this line — no longer an independently editable figure; adjust it by adding, editing or removing a line discounts[] entry. Affects item cost: reduces extCost and flows to the Cost Ledger. Must not exceed LineDiscountBasis. Converted from an authored decimal on 22 Sep 2026 when line discounts[] was added.","name":"discountAmount","type":"decimal","calculated":true},{"info":"Array of PurchaseOrderDiscount sub-documents — line-level discounts, the same shape used for header discounts[] (as line fees[] reuse PurchaseOrderFee). Line discounts AFFECT ITEM COST: they are summed into the line's calculated discountAmount, which reduces extCost and flows to the Cost Ledger. Percent entries resolve against LineDiscountBasis = (qty − qtyCancelled) × unitCost and apply independently (never compound). Each entry's discountReason must have appliesToLine = true. Added 22 Sep 2026.","name":"discounts","type":"array"},{"info":"Line-level expected delivery date override.","name":"expectedDate","type":"datetime"},{"info":"Extended cost (qty × unitCost − discountAmount).","name":"extCost","type":"decimal","calculated":true},{"info":"Array of PurchaseOrderFee sub-documents. Line-level fees (e.g. per-item freight, duties). Affect item cost.","name":"fees","type":"array"},{"info":"The Item being ordered. The pair (item, shipToLocation) is unique across lines for every PO type — which for Single, Allocated and Drop Ship reduces to one line per Item, since all their lines share the same destination.","name":"item","type":"entityRef","required":true,"relatedEntity":"Item"},{"info":"Line number within the purchase order.","name":"lineNo","type":"integer","required":true},{"info":"Ordered quantity.","name":"qty","type":"decimal","required":true},{"info":"Quantity cancelled from this line.","name":"qtyCancelled","type":"decimal"},{"info":"Quantity received against this line via Goods Receipts.","name":"qtyReceived","type":"decimal"},{"info":"The Sales Order this line was raised to fulfil. REQUIRED when the PO type is 'Drop Ship' — goods going straight to a customer must be attributable to the order that sold them. Optional otherwise, so a customer-ordered item bought in for collection can carry the same link without being drop shipped. Paired with salesOrderLineId; both are set together or neither is. Added 31 Aug 2026.","name":"salesOrder","type":"entityRef","relatedEntity":"Sales Order"},{"info":"The id of the specific SalesOrderLine this line fulfils. Line-level rather than header-level because a Sales Order line can be part-filled from stock and part drop shipped, which a header link could not express. Follows the sourceLineId precedent used by Stock Ledger for referencing a sub-document. Required whenever salesOrder is set.","name":"salesOrderLineId","type":"string"},{"info":"Destination for this line. Required, and inherited from the header defaultShipToLocation when the line is created. Only PO type 'Multi' may override it; for Single, Allocated and Drop Ship every line must equal defaultShipToLocation. For Drop Ship the goods do not physically go there — it is the owning location for accounting.","name":"shipToLocation","type":"entityDetail","required":true,"relatedEntity":"Location"},{"info":"Unit cost per item. Defaults from vendor unit cost at the applicable cost level.","name":"unitCost","type":"decimal","required":true}]},{"name":"PurchaseOrderStatus","extends":"OperationalSubDocument","properties":[{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"},{"info":"Current document lifecycle status of the purchase order.","name":"documentStatus","type":"enumeration","values":["Draft","Open","PartiallyReceived","Complete","Cancelled"],"required":true},{"info":"Operational status indicating whether the purchase order is actively being processed.","name":"operationalStatus","type":"enumeration","values":["Open","Closed"],"required":true}]},{"name":"PurchaseOrderDiscount","extends":"OperationalSubDocument","properties":[{"info":"Discount amount — authoritative for the rollup at its level (header discountAmount or line discountAmount). When discountType = 'Fixed' it is AUTHORED by the user, must be > 0, and never recomputed as lines change. When discountType = 'Percent' it is CALCULATED and not user-editable: ROUND(percent / 100 × basisAmount) to the PO currency's precision, re-resolved on every relevant line change. Storing the resolved value lets both rollups sum both types uniformly.","name":"amount","type":"decimal","required":true},{"info":"The basis the percent was resolved against at the last re-resolution. Depends on the level the entry sits at: for a HEADER entry it is DiscountBasis — SUM over lines of ((qty − qtyCancelled) × unitCost − line discountAmount); for a LINE entry it is LineDiscountBasis — (qty − qtyCancelled) × unitCost of that line. Populated only when discountType = 'Percent'; NULL for 'Fixed'. Kept for audit and invoice matching so the entry can be shown as 'percent of basisAmount = amount' without reconstructing history. Updated alongside amount on every line change.","name":"basisAmount","type":"decimal","calculated":true},{"info":"Description of this discount.","name":"description","type":"string"},{"info":"Reference to the Purchasing Discount reason — WHY the discount was given (volume rebate, trade discount, promotional allowance, etc.). Discount-side counterpart to PurchaseOrderFee.feeType. The reason must permit the level the entry sits at: appliesToHeader for header discounts[], appliesToLine for line discounts[]. Selecting a reason pre-fills discountType and percent from its defaults; the entry may override them. Does not decide cost treatment — that follows from the level. Optional for now so existing entries remain valid; description remains as a free-text note. Added 22 Sep 2026.","name":"discountReason","type":"entityDetail","relatedEntity":"Purchasing Discount"},{"info":"Whether the discount is entered as a percent or a fixed amount. 'Percent' re-resolves as lines change; 'Fixed' holds constant. Enum value renamed from 'Percentage' on 22 Sep 2026.","name":"discountType","type":"enumeration","values":["Percent","Fixed"],"required":true},{"info":"Discount percent as entered by the user, stored as entered (10 = 10%), never as a fraction. Conditionally required: REQUIRED and > 0 and ≤ 100 when discountType = 'Percent'; must be NULL when discountType = 'Fixed'. The retained input from which amount is resolved. Renamed from percentage on 22 Sep 2026 per property-naming-convention-standard decision A.","name":"percent","type":"decimal"}]},{"name":"PurchaseOrderLineAllocation","info":"Intended distribution of one PurchaseOrderLine's quantity across Locations, used by type = 'Allocated'. The goods still arrive as a single delivery at the PO's defaultShipToLocation; this records how much of that delivery is destined onward, which is what distinguishes Allocated from Multi (Multi actually ships separately per location). ALLOCATION MAY BE PARTIAL: SUM(qty) must not EXCEED the line's ordered qty, but it may be less, at submission and at any other time. Whatever is not allocated simply stays at defaultShipToLocation as on-hand stock in that location's stock ledger and is never transferred out — so a distribution centre can order 100, push 80 to stores and deliberately retain 20 as backstock, expressed on one line. Over-allocation is the only error, because it would instruct the movement of units the order never bought. location is unique within the array.","extends":"OperationalSubDocument","properties":[{"info":"The Location this quantity is allocated to. Unique within the line's allocations array.","name":"location","type":"entityDetail","required":true,"relatedEntity":"Location"},{"info":"Quantity of the line's Item allocated to this Location. Must be greater than zero. Across the array these must not exceed the line's qty; any shortfall remains at defaultShipToLocation.","name":"qty","type":"decimal","required":true}]}]},{"name":"Purchase Order Acknowledgement","class":"Operational","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"Vendor's confirmation of a Purchase Order — indicates acceptance, changes, backorders, or cancellation at header and line level. Key EDI document (855).","status":"stub","properties":[{"n":"ackDate","r":true,"t":"datetime"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. Always matches the company of the acknowledged Purchase Order."},{"n":"controlNumber","t":"string","info":"EDI control number for cross-referencing."},{"n":"lines","r":true,"t":"array","info":"Array of PurchaseOrderAcknowledgementLine sub-documents. Each line confirms the vendor's acceptance of a PO line."},{"n":"purchaseOrder","r":true,"t":"entityRef","re":"Purchase Order"},{"n":"status","r":true,"t":"schema","info":"Acknowledgement status from the vendor. Tracks current state and who/when it was last changed."}],"related":["Purchase Order","Vendor"],"inlineSchemas":[{"name":"POAcknowledgementStatus","schema":"schemas/purchase-order-acknowledgement/POAcknowledgementStatus.ts","properties":[{"info":"Vendor's acknowledgement response to the purchase order.","name":"status","type":"enumeration","values":["Accepted","Accepted with Changes","Backordered","Cancelled"],"required":true},{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"}]}]},{"name":"Purchasing Discount","class":"Dictionary","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"A discount reason applied to purchasing documents (e.g. volume rebate, trade discount, promotional allowance, damaged goods, pricing concession). The discount-side counterpart to Purchasing Fee: it classifies WHY a vendor discount was given, so discounts can be reported by reason, matched by reason across Purchase Order, Vendor Invoice and Bill, and mapped to GL accounts on posting. May be permitted at header level, line level, or both. Deliberately distinct from the Promotions Discount entity, which is a customer-facing price reduction rule applied at Sale.","status":"stub","properties":[{"n":"appliesToHeader","r":true,"t":"boolean","info":"When true the reason may be used on header-level (document) discounts. Header discounts are below-the-line and affect invoice matching only."},{"n":"appliesToLine","r":true,"t":"boolean","info":"When true the reason may be used on line-level discounts. Line discounts affect item cost. At least one of appliesToHeader / appliesToLine must be true."},{"n":"code","r":true,"t":"string","u":true,"info":"Human-readable key. Unique within Company."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company."},{"n":"defaultDiscountType","t":"enumeration","v":["Percent","Fixed"],"info":"Optional default for discountType when this reason is selected on a discount entry. The entry may override it."},{"n":"defaultPercent","t":"decimal","info":"Optional default percent, stored as entered (10 = 10%), pre-filled when this reason is selected and the entry's discountType is 'Percent'. Must be > 0 and ≤ 100 when set; must be NULL unless defaultDiscountType = 'Percent'. Copied onto the entry at selection time — changing it later never alters existing entries."},{"n":"name","r":true,"t":"string","u":true}],"ext":"LookupEntity","notes":"Added 22 Sep 2026 to close the asymmetry between PurchaseOrderFee (typed by Purchasing Fee) and PurchaseOrderDiscount (free-text description only). Decisions confirmed by the product owner 22 Sep 2026:\n\n1. NAME: Purchasing Discount — prefixed to avoid collision with the Promotions Discount entity, mirroring Purchasing Fee.\n2. LINE DISCOUNTS[]: Purchase Order lines carry a discounts[] array of PurchaseOrderDiscount, so line discounts can carry a reason too.\n3. LEVEL DECIDES COST, NOT REASON: header discounts are below-the-line (invoice matching only); line discounts flow into extCost and the Cost Ledger. No 'affects cost' flag is carried here, so the reason can never contradict the level.\n4. EARLY-PAYMENT / SETTLEMENT DISCOUNTS belong to Vendor Payment Term and are NOT recorded as Purchasing Discount entries, to avoid capturing the same discount twice.\n5. FLAG NAMING: appliesToHeader / appliesToLine. Purchasing Fee renamed to match (was isGlobalFee / isItemFee).\n\nRetire a reason by deactivating it (isActive false), never deleting, since discount entries on posted documents reference it.","related":["Purchase Order","Vendor Invoice","Bill","Vendor Credit","Purchasing Fee","Vendor Payment Term"],"bv":{"rules":[{"rule":"At least one of appliesToHeader or appliesToLine must be true","when":"always","field":"appliesToHeader","severity":"error"},{"rule":"defaultPercent must be NULL unless defaultDiscountType = 'Percent'; when set it must be > 0 and ≤ 100","when":"always","field":"defaultPercent","severity":"error"},{"rule":"Must not be used to record early-payment or settlement discounts — those are governed by Vendor Payment Term","when":"always","field":"code","severity":"info"}],"crossEntityConstraints":[{"rule":"A discount entry's discountReason must have appliesToHeader = true when used on a header discounts[] entry, and appliesToLine = true when used on a line discounts[] entry","entity":"Purchase Order"}]}},{"name":"Purchasing Fee","class":"Dictionary","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"A fee type applied to purchasing documents (e.g. freight, handling, duties). May be permitted at header level, line level, or both. Counterpart to Purchasing Discount.","status":"stub","properties":[{"n":"appliesToHeader","r":true,"t":"boolean","info":"When true the fee type may be used on header-level (document) fees. Header fees are below-the-line and affect invoice matching only. Renamed from isGlobalFee on 22 Sep 2026 to align with Purchasing Discount."},{"n":"appliesToLine","r":true,"t":"boolean","info":"When true the fee type may be used on line-level fees. Line fees affect item cost. At least one of appliesToHeader / appliesToLine must be true. Renamed from isItemFee on 22 Sep 2026 to align with Purchasing Discount."},{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company."},{"n":"name","r":true,"t":"string","u":true}],"ext":"LookupEntity","notes":"22 Sep 2026: isGlobalFee → appliesToHeader and isItemFee → appliesToLine, aligning with Purchasing Discount. As with discounts, the level a fee sits at — not the fee type — decides cost treatment: header fees are below-the-line, line fees affect item cost.","related":["Purchase Order","Purchasing Discount"],"bv":{"rules":[{"rule":"At least one of appliesToHeader or appliesToLine must be true","when":"always","field":"appliesToHeader","severity":"error"}],"crossEntityConstraints":[{"rule":"A PurchaseOrderFee's feeType must have appliesToHeader = true when used on header fees[], and appliesToLine = true when used on line fees[]","entity":"Purchase Order"}]}},{"name":"Push Device","class":"Operational","subsystem":"CONNECT","area":"Messaging","desc":"A registered push-notification endpoint for a single User on a single physical device or browser. Holds the provider token (APNs / FCM / SNS endpoint / Web Push subscription) that push delivery targets. Email needs no equivalent because the address lives on User; push does — a token is device-bound, rotates without warning, and is invalidated by the provider on app uninstall or reinstall. One User may have many Push Devices (phone, tablet, POS terminal browser); one device belongs to exactly one User. Push Notification Log references the Push Device it was sent to, and an invalid-token failure transitions this record to status='invalid' — the push analogue of hard-bounce suppression on Email Log.","status":"draft","properties":[{"n":"appVersion","t":"string","info":"Client application version at last registration or heartbeat. Used to gate payload features (e.g. rich media, action buttons) that older clients cannot render."},{"n":"deviceModel","t":"string","info":"Device model string reported by the client (e.g. 'iPhone15,3', 'Pixel 8'). Diagnostic only — never used for targeting."},{"n":"deviceToken","r":true,"t":"encrypted","info":"The provider push token or endpoint credential — APNs device token, FCM registration token, SNS platform endpoint ARN, or Web Push subscription JSON. Encrypted at rest: possession of a token permits sending notifications to that device, so it is treated as a credential, not an identifier. Never returned in API responses or logged; use tokenFingerprint for lookups and correlation."},{"n":"isEnabled","r":true,"t":"boolean","info":"User-controlled master switch for this specific device. False means the User has turned push off for this device only, without unregistering it. Distinct from status — a device can be enabled but invalid, or disabled but still active. Default false until the client confirms OS-level permission was granted. Per boolean-must-be-required."},{"n":"lastSeenAt","t":"datetime","info":"UTC timestamp of the most recent client heartbeat or token refresh. Drives stale-device reaping — a device unseen beyond the retention window transitions to status='stale' and is excluded from fan-out."},{"n":"osVersion","t":"string","info":"Operating system version at last registration. Diagnostic only."},{"n":"platform","r":true,"t":"enumeration","v":["ios","android","web"],"info":"Client platform. Determines payload shape (APNs aps dictionary vs FCM notification block vs Web Push JSON) and which provider-specific limits apply."},{"n":"provider","r":true,"t":"enumeration","v":["apns","fcm","sns","webPush"],"info":"Delivery provider this token is valid for. Denormalized onto Push Notification Log at send time so the log stays self-contained if the device is later re-registered against a different provider."},{"n":"pushDeviceNo","r":true,"t":"string","u":true,"info":"Human-readable identifier, unique within Company. Per entity-no-property convention."},{"n":"registeredAt","r":true,"t":"datetime","info":"UTC timestamp of first successful registration of this token. Immutable — a rotated token is a new Push Device record, not an update, so the log's device reference always resolves to the token actually used."},{"n":"status","r":true,"t":"schema","info":"Registration status of the device. Tracks current state plus who/when it was last changed. Follows the PushDeviceStatus inline schema, mirroring the EmailLogStatus pattern on Email Log."},{"n":"tokenFingerprint","r":true,"t":"string","u":true,"info":"SHA-256 hash of deviceToken, unique within Company. Enables duplicate-registration detection, log correlation, and support lookups without decrypting the token. This is the safe identifier to expose in API responses and diagnostics."},{"n":"user","r":true,"t":"entityRef","re":"User","info":"The User this device is registered to. Re-registering an existing token under a different User must revoke the prior record and create a new one — never reassign — so historical Push Notification Log rows never appear to have been delivered to the wrong person."}],"service":"APR Connect","ext":"OperationalDocument","notes":"PROPOSED — not yet reviewed. Fills the gap that made 'mobilePush' in Announcement.deliveryChannels unaddressable: nothing in the registry held a push token. Sits in CONNECT > Messaging alongside Email Template (the config half of the email pair); Push Device is the config/registry half of the push pair, Push Notification Log is the ledger half.\n\nOpen questions: (1) POS terminals — is a shared-terminal browser registration a Push Device tied to the signed-in User, or does it need a Location-scoped variant? (2) retention — how long before an unseen device is reaped entirely rather than left as 'stale'? (3) should isEnabled live here at all, or is per-device muting subsumed by Notification Preference? Current split: Notification Preference is per-User-per-category, Push Device isEnabled is per-device regardless of category. (4) Web Push subscriptions carry an endpoint URL plus two keys rather than a single opaque token — confirm the encrypted JSON blob shape is acceptable or whether webPush warrants its own inline schema.","related":["User","Company"],"bv":{"rules":[{"rule":"Must be unique within Company — a token may be registered to only one active Push Device at a time","when":"create","field":"TokenFingerprint","severity":"error"},{"rule":"Immutable after create. A rotated provider token creates a new Push Device and revokes the prior one","when":"update","field":"DeviceToken","severity":"error"},{"rule":"Immutable after create","when":"update","field":"RegisteredAt","severity":"error"},{"rule":"Cannot transition out of 'invalid' or 'revoked' — both are terminal","when":"update","field":"Status","severity":"error"},{"rule":"Must be false unless the client has confirmed OS-level notification permission","when":"always","field":"IsEnabled","severity":"warning"}],"lifecycle":{"states":["Active","Stale","Invalid","Revoked"],"transitions":[{"to":"Stale","from":"Active","conditions":["lastSeenAt older than the tenant stale-device window"]},{"to":"Active","from":"Stale","conditions":["Client heartbeat or token refresh received"]},{"to":"Invalid","from":"Active","conditions":["Provider returns unregistered / BadDeviceToken on send"]},{"to":"Invalid","from":"Stale","conditions":["Provider returns unregistered / BadDeviceToken on send"]},{"to":"Revoked","from":"Active","conditions":["User signs out or unregisters the device"]},{"to":"Revoked","from":"Stale","conditions":["User signs out or unregisters the device"]}],"initialState":"Active"},"calculations":[],"crossEntityConstraints":[{"rule":"user must reference a valid User within the same Company as this Push Device","entity":"User"},{"rule":"A send that fails with a provider unregistered/invalid-token code must transition this device to status='invalid' in the same unit of work","entity":"Push Notification Log"}]},"inlineSchemas":[{"name":"PushDeviceStatus","schema":"schemas/push-device/PushDeviceStatus.ts","properties":[{"info":"Current registration status. active = eligible for fan-out. stale = no heartbeat within the retention window; excluded from fan-out but recoverable on next check-in. invalid = provider rejected the token (unregistered / BadDeviceToken); terminal, requires a fresh registration. revoked = User signed out or explicitly unregistered the device; terminal.","name":"status","type":"enumeration","values":["active","stale","invalid","revoked"],"required":true},{"info":"User ID or system principal that last changed the status. System-set for provider-driven invalidation.","name":"changedBy","type":"string"},{"info":"ISO 8601 UTC timestamp of the last status change.","name":"changedDate","type":"datetime"}]}],"inheritance":{"inherited":["id","company","franchiseGroups","idmpKey","identifiers","uniqueValues","customData","tags","notes","recentActions","isDeleted","createdBy","createdDate","modifiedBy","modifiedDate"]}},{"name":"Push Notification Log","class":"Ledger","subsystem":"CONNECT","area":"Messaging","desc":"Immutable delivery audit trail for mobile and web push. One record per push sent to one Push Device. Direct mirror of Email Log: append-only, status updates written as new action entries rather than field overwrites, with provider-specific failure detail in place of SES bounce classification. The inherited sourceEntityType/sourceEntityId identify what triggered the push (e.g. 'Announcement' / announcementId, 'Chat Message' / messageId, 'Purchase Order' / poId), which is what lets any entity in the platform fan out a push and stay traceable — not just Announcement. Distinct from Announcement Acknowledgement: this records delivery to a device, that records a User acting on the content. A push can be delivered and never acknowledged.","status":"draft","properties":[{"n":"body","r":true,"t":"string","info":"Rendered notification body after variable interpolation, as handed to the provider. Stored verbatim so the audit trail shows what the recipient actually saw, independent of later edits to the source entity."},{"n":"category","r":true,"t":"enumeration","v":["operational","policy","training","marketing","system","compliance","chat","workflow"],"info":"Notification category, matched against Notification Preference to decide whether the push is sent at all. Aligns with Announcement.category and adds 'chat' (Chat Message @mentions and direct messages) and 'workflow' (approval requests, task assignments) for non-Announcement sources."},{"n":"collapseKey","t":"string","info":"Provider collapse identifier (APNs apns-collapse-id / FCM collapse_key). Successive pushes sharing a key replace one another on the device rather than stacking — used so a repeatedly updated order status shows one notification, not twelve."},{"n":"deepLink","t":"string","info":"In-app route the notification opens on tap (e.g. 'apr://announcement/{id}'). Null for informational pushes with no destination."},{"n":"deliveredAt","t":"datetime","info":"UTC timestamp of provider delivery confirmation. Null until confirmed — and permanently null on APNs, which confirms acceptance but not handset delivery. Absence of a value is not evidence of non-delivery."},{"n":"failureCode","t":"string","info":"Provider error code verbatim (e.g. APNs 'BadDeviceToken' / 'Unregistered', FCM 'NOT_REGISTERED'). Codes in the unregistered family transition the referenced Push Device to status='invalid'. Only populated when status is failed."},{"n":"failureReason","t":"string","info":"Provider diagnostic message accompanying failureCode. Only populated when status is failed."},{"n":"idmpKey","r":true,"t":"string","u":true,"info":"Idempotency key for push fan-out, unique within Company. Composed from sourceEntityId + recipientUser + pushDevice + delivery attempt. Overrides the optional LedgerEntry.idmpKey as required and unique per the ledger-idempotency-key-declared convention. A retried or replayed publish must collide here rather than double-sending: unlike a duplicate financial posting, the side effect is not a spurious row but a second notification in someone's pocket, and it cannot be compensated after the fact. Note that providerMessageId deliberately does NOT carry this contract — not every push provider guarantees an identifier, none is returned on a rejected send, and like Email Log.messageId it would be assigned only after the send was already made."},{"n":"openedAt","t":"datetime","info":"UTC timestamp the recipient tapped or activated the notification, reported by the client. Best-effort — a notification read on the lock screen and dismissed never reports. Never treat as acknowledgement; that is Announcement Acknowledgement's job."},{"n":"platform","r":true,"t":"enumeration","v":["ios","android","web"],"info":"Platform of the target device, denormalized at send time so the entry stays self-contained and queryable by platform without joining Push Device."},{"n":"provider","r":true,"t":"enumeration","v":["apns","fcm","sns","webPush"],"info":"Delivery provider used for this send, denormalized at send time. Determines how providerMessageId, failureCode, and deliveredAt semantics are interpreted."},{"n":"providerMessageId","t":"string","info":"Provider message identifier returned on accept (APNs apns-id, FCM message name, SNS MessageId). Null while status is queued and on rejected sends. Not unique-constrained — unlike SES, not every provider guarantees one, so dedupeKey carries the idempotency contract instead."},{"n":"pushDevice","r":true,"t":"entityRef","re":"Push Device","info":"The Push Device this notification was sent to. A fan-out to a User with three registered devices writes three entries."},{"n":"recipientUser","r":true,"t":"entityRef","re":"User","info":"The User who owns the target device. Required — unlike Email Log, push has no external-recipient case; a token only exists because a User registered it."},{"n":"status","r":true,"t":"schema","info":"Delivery status of the push. Tracks current state and who/when it was last changed. Follows the PushNotificationLogStatus inline schema, mirroring EmailLogStatus on Email Log."},{"n":"title","r":true,"t":"string","info":"Rendered notification title as handed to the provider. For Announcement-sourced pushes this is Announcement.title; the body derives from Announcement.summary."}],"service":"APR Connect","ext":"LedgerEntry","notes":"PROPOSED — not yet reviewed. Deliberately per-channel rather than a unified Notification Delivery ledger with a channel discriminator: it matches the pattern Email Log already set, and provider-specific fields do not generalize (bounceType is SES vocabulary; failureCode/collapseKey/TTL-expiry are push vocabulary). If a third channel (SMS) lands, revisit whether a shared base schema is warranted rather than a shared table. Carries no monetary values, so the LedgerEntry currency contract does not apply.\n\nIDEMPOTENCY, revised 31 Aug 2026. Originally drafted with a push-specific 'dedupeKey' on the reasoning that LedgerEntry provided no idmpKey. Review established the gap is general, not push-specific — a replayed Goods Receipt overstates inventory and a replayed fan-out sends a second email, and neither Email Log.messageId (assigned by SES after the send) nor Stock Ledger.ledgerLine (idempotent projection, read side only) actually prevents it. LedgerEntry now provides an optional idmpKey and the ledger-idempotency-key-declared convention requires each non-exempt descendant to override it as required and unique. This entity declares it; Stock Ledger, Cost Ledger, Price Ledger, Email Log and Announcement Acknowledgement do not yet — see registry question #19. Entity Action Log is exempt, because two identical action rows are two real events. Note that the convention is not machine-enforced — see registry question #18.\n\nOpen questions: (1) VOLUME — 10k users x multiple devices x a daily announcement is a large ledger. Options: per-tenant retention/archival policy on the LedgerEntry company partition key; or per-recipient rows only for compliance/policy categories with batch-level rows otherwise. Needs a decision before implementation, not after. (2) Should 'suppressed' live here, or in a separate audience-resolution record? Writing it here keeps one row per intended recipient and makes 'why did Store 42 not get this' answerable in a single query, at the cost of rows for pushes that never left the building. (3) Push Notification Template — deferred. Push copy is short and derives from source-entity fields; revisit if tenants need customizable per-state-transition push copy the way Email Template provides for email. (4) Confirm whether APNs' lack of a delivery receipt should be surfaced as a distinct status rather than a permanently-null deliveredAt.","related":["Push Device","User","Announcement","Notification Preference","Chat Message"],"bv":{"rules":[{"rule":"Must be unique within Company — one entry per source entity per recipient device per attempt. Overrides the optional LedgerEntry.idmpKey as required and unique, per ledger-idempotency-key-declared","when":"create","field":"IdmpKey","severity":"error"},{"rule":"Transitions are append-only — status can only move forward, never backward","when":"always","field":"Status","severity":"error"},{"rule":"Must be populated when status is 'failed'","when":"always","field":"FailureCode","severity":"error"},{"rule":"May only be set when status is 'sent' or 'delivered' — a failed or suppressed push cannot be opened","when":"update","field":"OpenedAt","severity":"error"},{"rule":"Rendered content is immutable once written — later edits to the source entity do not rewrite the log","when":"update","field":"Body","severity":"error"}],"lifecycle":{"states":["Queued","Sent","Delivered","Failed","Expired","Suppressed"],"transitions":[{"to":"Sent","from":"Queued","conditions":["Provider accepts the send request"]},{"to":"Failed","from":"Queued","conditions":["Provider rejects the send request (invalid token, payload too large, throttled beyond retry)"]},{"to":"Suppressed","from":"Queued","conditions":["Notification Preference, quiet hours, or non-active Push Device excludes the recipient at send time"]},{"to":"Delivered","from":"Sent","conditions":["Provider delivery receipt received (FCM / Web Push)"]},{"to":"Failed","from":"Sent","conditions":["Asynchronous provider failure callback received"]},{"to":"Expired","from":"Sent","conditions":["Provider TTL elapsed before the device came online"]}],"initialState":"Queued"},"calculations":[],"crossEntityConstraints":[{"rule":"pushDevice must exist; a failureCode in the unregistered family must transition that Push Device to status='invalid'","entity":"Push Device"},{"rule":"recipientUser must belong to the same Company as this entry, and must be the owner of pushDevice at send time","entity":"User"},{"rule":"Category must be evaluated against the recipient's Notification Preference before send; an excluded recipient is written with status='suppressed', not skipped","entity":"Notification Preference"},{"rule":"When sourceEntityType='Announcement', one entry is written per targeted User per active Push Device at publish","entity":"Announcement"}]},"inlineSchemas":[{"name":"PushNotificationLogStatus","schema":"schemas/push-notification-log/PushNotificationLogStatus.ts","properties":[{"info":"Current delivery status. queued = accepted for fan-out, not yet handed to the provider. sent = provider accepted. delivered = provider confirmed handset receipt (FCM/Web Push only). failed = provider rejected or permanently failed. expired = provider TTL elapsed before the device came online. suppressed = never sent because Notification Preference, quiet hours, or a non-active Push Device excluded the recipient; recorded rather than dropped so the audit shows the recipient was in the audience and why they got nothing.","name":"status","type":"enumeration","values":["queued","sent","delivered","failed","expired","suppressed"],"required":true},{"info":"User ID or system principal that last changed the status. System-set for provider callback transitions.","name":"changedBy","type":"string"},{"info":"ISO 8601 UTC timestamp of the last status change.","name":"changedDate","type":"datetime"}]}],"inheritance":{"inherited":["id","company","entryDate","sourceEntityType","sourceEntityId","createdBy","createdDate"]}},{"name":"RFM Group","class":"Dictionary","subsystem":"CONNECT","area":"Customers","desc":"Customer segmentation classification derived from RFM (Recency, Frequency, Monetary) analysis. Groups customers by purchase behavior — typical segments include Champions, Loyal Customers, Potential Loyalists, At Risk, Hibernating, and Lost. Used to drive targeted marketing, retention campaigns, and service tiering.","status":"draft","properties":[{"n":"code","r":true,"t":"string","u":true,"info":"Human-readable key for the RFM segment (e.g. CHAMPIONS, AT_RISK, HIBERNATING). Unique within the Company."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"The Company tenant this RFM Group belongs to. Required for tenant isolation."},{"n":"description","t":"string","info":"Description of the segment definition and criteria — e.g. 'Customers who purchased within the last 30 days with high frequency and spend'."},{"n":"frequencyScore","t":"integer","info":"Frequency score (typically 1–5) representing how often customers in this segment purchase."},{"n":"monetaryScore","t":"integer","info":"Monetary score (typically 1–5) representing the spend tier of customers in this segment."},{"n":"name","r":true,"t":"string","u":true,"info":"Display name of the segment (e.g. 'Champions', 'At Risk')."},{"n":"recencyScore","t":"integer","info":"Recency score (typically 1–5) representing how recently customers in this segment last purchased."}],"ext":"LookupEntity","related":["Customer","Company"]},{"name":"Replenishment Plan","class":"Operational","subsystem":"CONNECT","area":"Merchandising","desc":"One replenishment run: the buy a planner reviewed on a given day, the policy that produced it, the evidence behind every line, and the purchase and transfer orders it became. A plan is the answer to \"what should we buy, and why that quantity\" — captured as a document so the second half of the question survives after the orders are raised.\n\nTHE DERIVATION IS THE DOCUMENT. A recommendation of 150 units is worth nothing on its own; the same number is trustworthy or absurd depending on whether the rate behind it counted days the item was out of stock, whether a purchase order already on the water was netted off, whether the window it covers spans December or April, and which of three rules set the target. Every one of those inputs is carried on the line rather than recomputed on demand, because they are only reconstructable while the policy, the curve and the position are all still what they were at run time — and none of the three stays still. A plan that stores quantities and re-derives explanations is a plan that explains today's policy applied to today's stock, which is not what anyone ordered against.\n\nTHE POLICY IS SNAPSHOT, NOT REFERENCED. method, the analysis window, the out-of-stock treatment, the planned-on-hand inclusion flags and the company weeks-of-supply default are copied into the plan at run time. Referencing the live settings instead would mean that changing a target weeks-of-supply in Settings silently rewrites the stated basis of every plan ever run — the same failure mode the Retail Calendar definition is frozen to avoid, and the reason a committed plan can still be read eight weeks later and understood.\n\nQUANTITY HAS THREE STATES, NOT TWO. suggestedQty is what the model produced. orderQty is what will be ordered. A NULL orderQty means the line still tracks the model, so a policy change moves it; a typed value pins the line at the buyer's number; and a typed ZERO is a decision not to buy, which is a judgement and must be distinguishable from an untouched line that happens to recommend nothing. Collapsing null and zero into one field is the single most consequential modelling mistake available here: it turns \"leave this one alone\" and \"I have looked at this and the answer is no\" into the same record.\n\nSCOPE IS PART OF THE PLAN, NOT A QUERY. A run covers a stated set of locations, vendors and classifications. A plan whose scope is implicit cannot answer whether an item was excluded deliberately or never considered, which is the first question asked when something runs out.\n\nGRAIN. One plan per run. Lines are at Item x Location, and the plan holds the lines within its declared scope that the run put in front of a person — not the full precomputed recommendation set for the estate, which for a 20,000-item, 200-location tenant is four million item-locations and is a batch projection rather than a document. See the open question on where that projection lives.","status":"draft","properties":[{"c":true,"n":"adjustedLineCount","t":"integer","info":"Lines whose orderQty was typed by a person rather than left tracking the model. The numerator of the suggestion acceptance rate the PRD makes a launch metric, and the reason that metric is two-sided: near-zero adjustment means nobody is checking the recommendation, and near-total adjustment means nobody trusts it. A one-sided 'higher is better' reading of this count would hide the first failure entirely."},{"n":"analysisWindow","r":true,"t":"schema","info":"ReplenishmentAnalysisWindow inline schema. The span of history the sales rate was measured over, the mode that selected it, and whose history was used. Snapshot, not a reference: the window is what makes a rate defensible, and a plan that cannot state its window states a rate with no basis."},{"c":true,"n":"belowVendorMinimumDocumentCount","t":"integer","info":"Generated purchase orders whose value falls below the vendor's minimum order value. Surfaced before commit rather than discovered at the vendor, and the figure that argues for consolidation: in the prototype's representative estate, buying direct to store produced 57 purchase orders of which 36 failed their vendor minimum, against 6 orders and none failing when consolidated to the distribution centre."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Redundant with the same property on OperationalDocument, which this entity extends; retained as an explicit local declaration and harmless. (The original note here cited the 01 Sep 2026 audit's claim that no base schema provides company. That claim was withdrawn on 03 Sep 2026 — it came from reading each base schema's stale `provides` summary rather than its `properties`.) Franchise-group scoping is separate and inherited: franchiseGroups narrows WHICH franchisee's estate a plan covers within the tenant."},{"n":"currencyCode","r":true,"t":"string","info":"ISO 4217 alpha-3 code for every monetary figure on the plan and its lines. Declared as a flat string rather than entityDetail to Currency, following the Financial Summary and ledger precedent: a committed plan is a historical record and must be self-contained, and a Location's market assignment can change after the orders are raised. A plan spanning locations that trade in different currencies is rejected rather than silently mixed — see the business rules."},{"c":true,"n":"documentCount","t":"integer","info":"How many purchase and transfer orders the selection becomes. Shown before commit, because the answer changes the decision: the same units bought direct to store rather than consolidated is an order of magnitude more documents, more freight and more receiving, traded against clearing vendor minimums."},{"n":"generatedDocuments","r":true,"t":"array","info":"ReplenishmentPlanDocument entries — one per purchase or transfer order the plan produced, with the vendor minimum check that was applied. Empty array default while the plan is Draft or InReview. Written at commit and never edited afterwards: the documents themselves then live their own lifecycle, and a plan records what it raised, not what subsequently happened to it."},{"c":true,"n":"lineCount","t":"integer","info":"Recommendation lines in the plan. Denominator for the adjustment rate and the coarse measure of whether the scope was sane — a run that puts 40,000 lines in front of a person has not been scoped, it has been dumped."},{"n":"lines","r":true,"t":"array","info":"ReplenishmentPlanLine entries at Item x Location grain, each carrying its complete derivation. Modelled as an inline schema on the parent per the line-entities-are-inline-schemas convention, and read as a set: a line has no meaning outside the run whose policy produced it. Empty array default. A line is never edited after commit — the plan is the record of what was decided, and a later change of mind is a later plan."},{"n":"planningDate","r":true,"t":"date","info":"The date the plan is anchored on: the day from which lead time and the protection window are measured, and the boundary between history and the window being bought for. A calendar date with no time component, because every window in the model is measured in whole trading days. Usually today, but a run prepared on Friday for Monday's buying meeting is anchored on Monday, and the difference is a weekend — which on the FY2025 client shape is 2.3 average days of demand, not 2."},{"n":"policy","r":true,"t":"schema","info":"ReplenishmentPolicySnapshot inline schema. Copy of the settings that governed this run — method, out-of-stock treatment, the company weeks-of-supply default, the planned-on-hand inclusion flags, consolidation mode and case rounding. Snapshot rather than a reference to the live settings, so that a plan committed eight weeks ago still states the policy it was actually run under."},{"n":"replenishmentPlanNo","r":true,"t":"string","u":true,"info":"Human-readable identifier, unique within Company. Per the entity-no-property convention. What a buyer quotes when asking why a line ordered what it did."},{"n":"retailCalendar","r":true,"t":"entityDetail","re":"Retail Calendar","info":"The calendar every week in this plan resolves through — the analysis window, the protection window and the curve integration. Must be the same calendar the applied Sales Curve was built on: mismatched calendars integrate different weeks of the year against each other and produce a quantity that is wrong by a plausible amount with no error raised."},{"n":"runCompletedAt","t":"datetime","info":"When computation finished and the lines became reviewable. UTC. Null while a run is in flight or failed. Together with runStartedAt this is the measurement behind the PRD's sub-90-minute cycle-time goal, which is otherwise a claim nobody can check."},{"n":"runStartedAt","t":"datetime","info":"When computation began. UTC."},{"n":"scope","r":true,"t":"schema","info":"ReplenishmentPlanScope inline schema. The locations, vendors, classifications and stock groups the run covered. Stored rather than implied by the lines present, because the two answer different questions: the lines say what was recommended, the scope says what was CONSIDERED. An item that produced no line because it needed nothing and an item that was never in scope look identical without it, and they are the opposite of each other when something runs out."},{"n":"status","r":true,"t":"schema","info":"ReplenishmentPlanStatus inline schema. Draft while computing, InReview while a planner is working it, Committed once documents are raised and the plan is frozen, Cancelled if abandoned, Failed if the run did not complete. Modelled as an inline schema with the audit stamp on the Retail Calendar and Sales Curve precedent: committing a plan raises orders against a vendor, which is an accountable act."},{"c":true,"n":"suggestedOrderValue","t":"decimal","info":"Total value of the model's suggestions at unit cost, before any buyer adjustment. Kept alongside totalOrderValue because the gap between them is the plan's own summary of how much of the buy was judgement rather than policy — a figure that is invisible if only the committed total is stored."},{"c":true,"n":"totalOrderValue","t":"decimal","info":"Total value of the effective order quantities at unit cost. The number a buyer commits to and the one that lands in open-to-buy."}],"service":"Demand Planning","ext":"OperationalDocument","notes":"PROPOSED — not yet reviewed. Created 03 Sep 2026 from the Merchandising: Demand Planning, Forecasting and Replenishment PRD (Sean Finnigan, 03 Sep 2026), requirements R8 through R12 plus R22 (run history). Companion to Sales Curve, created in the same pass.\n\nWHY A RUN DOCUMENT AND NOT A POLICY OBJECT. Both were on the table. A policy object — a named, reusable set of settings — is the thing that lives in Settings under change control (PRD R13), and it will probably exist eventually. But the artefact the module has to produce is the one a franchisor can be held to: what was recommended, on what evidence, what a person changed, and what was raised as a result. That is a document with a lifecycle, not a configuration record. The policy is therefore carried INSIDE the plan as a snapshot, which also settles the harder question — whether a plan re-read in eight weeks shows the policy it ran under or the policy in force today. It shows the former, which is the only version that explains an order already placed.\n\nWHY Operational AND NOT Transactional. TransactionalDocument's contract is that the document writes to at least one internal ledger on posting. A committed plan writes nothing to the Stock Ledger; it creates Purchase Orders and Transfer Orders, which have their own lifecycles and post their own movements when goods actually move. Operational plus an explicit lifecycle states that honestly. Same reasoning that put Financial Summary on Operational.\n\nWHY THE LINES ARE INLINE. Required by the line-entities-are-inline-schemas convention, and correct independently: a line has no meaning outside the run whose policy produced it, is never referenced by another document, and is always read as part of the set.\n\nTHE UNRESOLVED SCALE QUESTION, stated rather than buried. The PRD's own target estate is 20,000 items x 200 locations = four million item-locations, recomputed nightly (R14). This entity is NOT that. A Replenishment Plan is a scoped run that a person reviewed — a vendor, a region, a classification, a week's buying meeting — and its lines are the lines that were put in front of them. Where the full nightly recommendation set lives is a separate modelling decision, and the registry's own precedent points at a Balance-class projection (the Inventory Position shape: mutable, recomputed, one row per Item x Location) rather than at a document. Logged as an open question. Deciding it the other way — one plan holding four million inline lines — would make the document unreadable and the storage pathological.\n\nMODELLING CHOICES worth challenging in review:\n- Three-state order quantity. NULL tracks the model, a typed value pins the line, a typed ZERO is a decision not to buy. isAdjusted disambiguates the third from the first. Collapsing null and zero is the mistake this design exists to avoid.\n- Both sales rates are stored on every line, not just the one that was used. The calendar-day rate and the in-stock-only rate answer different questions and the choice between them is a policy setting; storing only the applied rate means a planner cannot see what the other policy would have produced without a re-run. The out-of-stock share sits beside them because it is what makes the divergence interpretable.\n- appliedRule is required on every line. Three replenishment methods produce quantities through five distinct rules, and a quantity whose rule is unknown cannot be argued with. This is the property that turns \"the system says 66\" into \"below minimum, so filled to maximum\".\n- Provenance properties (targetWeeksOfSupplySource, curveSource, stockLimitSource, historyBasis) exist so a planner can tell a POLICY problem from a DATA problem — the distinction the PRD's first goal rests on.\n- Late inbound is stored as its own quantity rather than netted out. Stock arriving after the window it would have covered is excluded from cover but shown, because a long-lead line left short by stock arriving too late is a specific, recurring failure and it should be visible on the line that suffers it.\n- currencyCode is a flat ISO string, not entityDetail to Currency, on the ledger and Financial Summary precedent: a committed plan is a historical record and must be self-contained.\n- No dependency on the statistical forecaster. demandBasis on the policy snapshot admits a forecast plan as a source, but all three replenishment methods run on analysed sales plus a curve, which is what allows the PRD's Phase 1 to ship without Phase 2.\n\nDEPENDENCIES THE PRD MARKS AS BLOCKING, and their state in the registry today:\n- Date-resolved, stream-split inbound (R5a). Item Stock's StockQuantities.incoming is a single undated aggregate combining purchase orders and transfers. The line's incomingPurchaseOrderQty / inboundTransferQty / inboundAllocationQty split and its excludedLateInboundQty cannot be populated from it. Blocking; logged.\n- Historical availability (R4a). inStockDayRate and outOfStockShare require a daily sellable/not-sellable series reconstructed from Stock Ledger.balanceQty. No such series exists. Blocking for the out-of-stock treatment.\n- Allocated-out quantity (R5c). Item Stock documents Available = SOH - Allocated - Reserved but StockQuantities carries no allocated field. allocatedQty on the line has no source until that is reconciled.\n- Stock Limit Group is a two-property stub with no schema. minimumQty, maximumQty and stockLimitSource have nowhere to resolve from, and the Combined method is undefined without them.\n- Item Stock Group is a two-property stub described as \"items that share common stocking rules, replenishment strategies\" — the intended home of the classification tier for both weeks of supply and curve assignment.\nThese are recorded here rather than worked around, because a line that silently reads a single undated incoming aggregate as if it were window-gated inbound produces exactly the double-ordering the module exists to stop.","related":["Item","Item Stock","Location","Vendor","Purchase Order","Transfer Order","Sales Curve","Retail Calendar","Stock Ledger","Stock Limit Group","Item Stock Group","Classification","Product","Company","Inventory Position"],"bv":{"rules":[{"rule":"replenishmentPlanNo must be unique within the Company","when":"create","field":"ReplenishmentPlanNo","severity":"error"},{"rule":"The inherited idmpKey is required and unique within Company, composed from company + planningDate + a hash of the scope. A run is triggered by a scheduler and by a person, is retryable by both, and two identical runs raising two identical sets of purchase orders is the failure the key exists to prevent","when":"always","field":"IdmpKey","severity":"error"},{"rule":"IMMUTABLE ONCE COMMITTED. No property on the plan or any of its lines may change after status.status = 'Committed'. Purchase and transfer orders now exist against a vendor and a distribution centre; editing the plan afterwards rewrites the stated basis of an order already placed. A change of mind is a new plan","when":"update","field":"Status","severity":"error"},{"rule":"A plan may only move to Committed from InReview, and only with at least one line whose effective order quantity is greater than zero. Committing a plan that raises nothing is a Cancel, and the two mean different things when the run is reviewed later","when":"update","field":"Status","severity":"error"},{"rule":"generatedDocuments must be empty while status is Draft, InReview, Cancelled or Failed, and non-empty once Committed. A plan that claims Committed with no documents cannot be reconciled against what was actually raised","when":"always","field":"GeneratedDocuments","severity":"error"},{"rule":"Every Location in scope must operate in the plan's currencyCode. A plan spanning locations trading in different currencies is rejected rather than silently mixed — totalOrderValue would otherwise be a sum of unlike amounts that looks entirely normal","when":"always","field":"CurrencyCode","severity":"error"},{"rule":"analysisWindow.endDate must be strictly before planningDate. A window overlapping the planning date measures a rate from a period the plan is also buying for, double-counting the overlap","when":"always","field":"AnalysisWindow","severity":"error"},{"rule":"Every line's applied Sales Curve must reference the same Retail Calendar as the plan. A curve indexed on a 4-4-5 January-anchored week 47 integrated against an NRF 4-5-4 week 47 compares two different weeks of the year and produces a plausible quantity with no error raised","when":"always","field":"RetailCalendar","severity":"error"},{"rule":"A line's orderQty of NULL means the line tracks suggestedQty; a typed value pins it; a typed zero is a decision not to buy. isAdjusted must be true whenever orderQty is non-null, and false whenever it is null. The pairing is what keeps a typed zero distinguishable from an untouched line — the two are opposite decisions","when":"always","field":"Lines","severity":"error"},{"rule":"adjustedBy and adjustedAt are required on any line where isAdjusted is true. In a franchise estate the franchisor buys and the franchisee lives with the result; an unattributed override is not an audit trail","when":"always","field":"Lines","severity":"error"},{"rule":"Re-running the policy over a plan InReview must move untouched lines and leave adjusted lines pinned at their typed quantity. A re-run that resets a buyer's numbers destroys the review in progress; one that leaves untouched lines stale reports a policy that is no longer in force","when":"update","field":"Lines","severity":"error"},{"rule":"Every line must carry an appliedRule. A quantity whose governing rule is unknown cannot be defended, and the first goal of the module is that every quantity is explainable line by line","when":"always","field":"Lines","severity":"error"},{"rule":"A line whose stockLimitSource is NoLimitSet must carry appliedRule = 'NoStockLimitSet' or 'WeeksOfSupply', never a fill-to-maximum rule. Items without configured limits fall through to weeks of supply and are labelled; they are never blocked and never treated as having a maximum of zero","when":"always","field":"Lines","severity":"error"},{"rule":"suggestedQty must be a whole multiple of caseQty when the policy has roundToWholeCases set. A typed orderQty is accepted as entered and flagged through isPartCase instead — a buyer typing a part case may know something about the vendor that the case quantity does not","when":"always","field":"Lines","severity":"error"},{"rule":"Inbound landing after protectionWindowEndDate must be reported in excludedLateInboundQty and excluded from plannedOnHandQty when the policy gates inbound by window. Counting it as cover leaves a long-lead line short by stock that arrives weeks after the shelf empties, and the line looks covered until it is not","when":"always","field":"Lines","severity":"error"},{"rule":"outOfStockShare must be present on any line whose appliedRateBasis is InStockDaysOnly. The in-stock rate without the out-of-stock share is a rate with no way to judge how far it has been extrapolated","when":"always","field":"Lines","severity":"warning"},{"rule":"A line whose historyBasis is PooledRescaled must carry a volumeIndexFactor. A borrowed rate multiplied by an unstated scalar is a number, not evidence","when":"always","field":"Lines","severity":"error"},{"rule":"A plan whose lineCount exceeds a configured review threshold should warn before it is opened. A run that puts tens of thousands of lines in front of a person has not been scoped, and the review it invites is theatre","when":"always","field":"LineCount","severity":"warning"},{"rule":"A Committed plan is never soft-deleted. It is the record of why working capital was committed and what a franchisee was allocated","when":"delete","field":"IsDeleted","severity":"error"}],"lifecycle":{"states":["Draft","InReview","Committed","Cancelled","Failed"],"transitions":[{"to":"InReview","from":"Draft","conditions":["Computation completed","runCompletedAt recorded","At least one line produced, or an empty result explicitly reported with its scope"]},{"to":"Failed","from":"Draft","conditions":["Computation did not complete","failureReason recorded"]},{"to":"Draft","from":"Failed","conditions":["Re-run initiated; failureReason retained rather than cleared"]},{"to":"InReview","from":"InReview","conditions":["Policy re-run over the same scope","Untouched lines move with the model; adjusted lines stay pinned"]},{"to":"Committed","from":"InReview","conditions":["At least one line with an effective quantity greater than zero","Vendor minimum failures surfaced and accepted or resolved","Documents raised and recorded in generatedDocuments","changedBy and changedDate recorded","Document-creation permission held"]},{"to":"Cancelled","from":"InReview","conditions":["Abandoned without raising documents","Plan retained — a run deliberately not acted on is informative"]},{"to":"Cancelled","from":"Draft","conditions":["Abandoned before review"]}],"initialState":"Draft"},"calculations":[{"info":"The three-state resolution, in one place. A null tracks the model; a typed value pins; a typed zero is a decision not to buy and resolves to zero, not to the suggestion.","name":"effectiveOrderQty","formula":"line.orderQty IS NULL ? line.suggestedQty : line.orderQty","trigger":"query-time"},{"info":"De-seasonalizes measured sales into an annual rate. The step that lets an October run buy for December.","name":"impliedAnnualDemandQty","formula":"line.analysedSalesQty / line.analysedWindowCurveShare","trigger":"On run"},{"info":"Re-seasonalizes the annual rate onto the window actually being bought for.","name":"requirementQty","formula":"line.impliedAnnualDemandQty x line.protectionWindowCurveShare (+ safety stock when enabled)","trigger":"On run"},{"info":"EVERY rule measures against this, never against raw on-hand. Each term is stored beside the total because the total drives the quantity and the terms explain it.","name":"plannedOnHandQty","formula":"onHandQty - (includeAllocated ? allocatedQty : 0) - (includeCommitted ? committedQty : 0) + (includeIncomingPurchaseOrders ? incomingPurchaseOrderQty : 0) + (includeInboundTransfers ? inboundTransferQty : 0) + (includeInboundAllocations ? inboundAllocationQty : 0), counting only inbound landing on or before protectionWindowEndDate when windowGateInbound is set","trigger":"On run"},{"name":"shortfallQty","formula":"MAX(0, requirementQty - plannedOnHandQty)","trigger":"On run"},{"info":"Three methods, five rules, one recorded provenance value per line. The Combined branch that fills a below-minimum recommendation to MAXIMUM is the specified behaviour and is where a slow mover's whole quantity comes from the limit values rather than from demand — implemented as specified and labelled, not quietly softened.","name":"suggestedQty","formula":"SalesAndTargetWeeksOfSupply: CEIL(shortfallQty / caseQty) x caseQty. StockLimit: plannedOnHandQty <= minimumQty ? CEIL((maximumQty - plannedOnHandQty) / caseQty) x caseQty : 0. Combined: run the first, then hold inside the limits — capped at maximumQty above, filled to maximumQty below minimumQty. appliedRule records which branch fired","trigger":"On run"},{"info":"What the order actually buys. Cover well above target is the overstock signal.","name":"coverWeeks","formula":"(plannedOnHandQty + effectiveOrderQty) / (appliedSalesRate x 7), reshaped by the curve over the forward span","trigger":"query-time"},{"name":"totalOrderValue","formula":"SUM(line.effectiveOrderQty x line.unitCost)","trigger":"On any line change"},{"info":"Kept beside totalOrderValue so the gap between policy and judgement is visible.","name":"suggestedOrderValue","formula":"SUM(line.suggestedQty x line.unitCost)","trigger":"On run"},{"info":"With lineCount, the suggestion acceptance rate. Deliberately read against a two-sided band: above roughly 85% acceptance suggests nobody is checking, below roughly 60% suggests nobody trusts it.","name":"adjustedLineCount","formula":"COUNT(lines WHERE isAdjusted = true)","trigger":"On any line change"},{"name":"lineCount","formula":"COUNT(lines)","trigger":"On run"},{"name":"documentCount","formula":"COUNT(generatedDocuments)","trigger":"On commit"},{"info":"Evaluated before commit with the consolidation remedy named. Discovering it at the vendor is a phone call and a delay.","name":"belowVendorMinimumDocumentCount","formula":"COUNT(generatedDocuments WHERE meetsVendorMinimum = false)","trigger":"On document generation, before commit"}],"crossEntityConstraints":[{"rule":"Every line's sales history, running balance and availability derive from Stock Ledger entries at day x item x location grain. The immutability of the ledger is what makes a committed plan reproducible — a rate computed from a mutable projection cannot be re-derived to check it","entity":"Stock Ledger"},{"rule":"onHandQty, committedQty and inbound quantities read from Item Stock. StockQuantities.incoming is a single UNDATED aggregate combining purchase orders and transfers, and StockQuantities carries no allocated field despite Item Stock documenting Available = SOH - Allocated - Reserved. The stream split, the window gate and allocatedQty all depend on resolving both gaps — blocking, and logged rather than worked around","entity":"Item Stock"},{"rule":"Committing a plan creates Purchase Orders — one per vendor per store under direct-to-store, one per vendor for the chain under consolidation. The plan does not post inventory movement; the resulting documents do, when goods actually move","entity":"Purchase Order"},{"rule":"Committing a plan creates Transfer Orders for items served from a distribution centre, and for allocating a consolidated vendor order out to stores. Where the DC cannot cover the chain requirement the line reverts to a vendor order; fair-sharing constrained DC stock across stores is deliberately out of scope","entity":"Transfer Order"},{"rule":"Each line's applied curve resolves through the assignment precedence — product override, classification override, then a pattern derived from the item's own demand — and must reference the plan's Retail Calendar. The reference properties that carry that precedence are not yet declared anywhere in the registry","entity":"Sales Curve"},{"rule":"The analysis window, the protection window and every retail-week comparison resolve through the plan's Retail Calendar. Year-on-year comparison uses the calendar's comparable-period mapping rather than an ordinal offset, which after a 53-week year would shift every comparison by seven days while every figure still looked plausible","entity":"Retail Calendar"},{"rule":"minimumQty, maximumQty and the governing group resolve from Stock Limit Group, highest priority first where several cover a location. The entity is a two-property stub with no schema today, so the StockLimit and Combined methods have nothing to resolve against","entity":"Stock Limit Group"},{"rule":"The classification tier of both the weeks-of-supply hierarchy and curve assignment is intended to live on Item Stock Group — currently a two-property stub described as items sharing common stocking rules and replenishment strategies","entity":"Item Stock Group"},{"rule":"targetWeeksOfSupply resolves through the hierarchy settled on 03 Sep 2026: Item.weeksOfSupply supersedes Product.defaultWeeksOfSupply at read time, with Classification.defaultWeeksOfSupply seeding the Product value. Live fallback, not copy-on-create — editing a Product re-plans every Item that has not overridden it, which is the point of setting the value at style level","entity":"Product"},{"rule":"Lead time and minimum order value read from Vendor and are snapshotted onto the line and the document, so a later change to the vendor record does not rewrite whether a past order cleared its minimum","entity":"Vendor"},{"rule":"unitCost carries the cost restriction group, consistent with Item.currentUnitCost and its siblings. A planning surface must not become the route by which cost is read by someone barred from it on the product screen","entity":"Item"}]},"inlineSchemas":[{"name":"ReplenishmentPlanLine","info":"One recommendation, at Item x Location grain, carrying its complete derivation in computation order: what sold, at what rate, against what seasonal shape, over what window, against what position, under which rule, rounded how, leaving what cover. The line is deliberately wide. Every property here is an input a planner needs to distinguish a POLICY problem from a DATA problem without leaving the row — and every one of them is only reconstructable while the policy, the curve and the position are still what they were at run time, which none of them stays. A narrow line that stores quantities and re-derives explanations explains today's policy applied to today's stock, which is not what anyone ordered against.","properties":[{"info":"When the order quantity was last typed. UTC. Null on an untouched line.","name":"adjustedAt","type":"datetime"},{"info":"The User who typed the order quantity. Null on an untouched line. In a franchise estate the franchisor buys and the franchisee lives with it, so who chose a quantity is part of the record, not metadata about it.","name":"adjustedBy","type":"string"},{"info":"Stock physically present but promised to an outbound transfer or sales order. Deducted from planned on hand when the policy includes it. NOTE: Item Stock documents Available = SOH - Allocated - Reserved but StockQuantities carries no allocated field — this property has no source in the current model and the inconsistency is logged.","name":"allocatedQty","type":"integer","required":true},{"info":"Units sold at this Item x Location during the analysis window. The raw evidence: every rate below is this number divided by a count of days.","name":"analysedSalesQty","type":"decimal","required":true},{"info":"The applied curve's share of the annual year that the analysis window covers. The denominator that de-seasonalizes measured sales into an annual rate, and the reason a flat average mis-sizes every window — eight weeks of October is not 8/52 of a year's demand.","name":"analysedWindowCurveShare","type":"decimal","required":true},{"info":"Which of the two rates actually drove this line, per the plan's out-of-stock policy. Stored on the line and not only on the policy, because a line borrowed from pooled history may not have an availability series of its own and can legitimately fall back.","name":"appliedRateBasis","type":"enumeration","values":["CalendarDays","InStockDaysOnly"],"required":true},{"info":"WHICH RULE SET THE TARGET. The most important property on the line. Three methods produce quantities through five distinct rules, and a quantity whose rule is unknown cannot be argued with. This is what turns 'the system says 66' into 'below minimum, so filled to maximum' — and it is what makes the Combined method's known behaviour on slow movers inspectable rather than mysterious: a low-demand item whose weeks-of-supply requirement lands under the minimum is filled to MAXIMUM, and this property is where that shows.","name":"appliedRule","type":"enumeration","values":["WeeksOfSupply","CappedAtMaximum","AtMinimumFillToMaximum","BelowMinimumFillToMaximum","NoStockLimitSet"],"required":true},{"info":"The units-per-day rate the requirement was built from — whichever of calendarDayRate and inStockDayRate the policy selected, after any volume rescaling.","name":"appliedSalesRate","type":"decimal","required":true},{"info":"Average weekly units over the analysis window. Not used in the arithmetic — it is the figure a buyer reads to sanity-check a recommendation against their own sense of the item, on the row, without opening anything.","name":"averageWeeklySalesQty","type":"decimal","required":true},{"info":"Units per day over EVERY day of the analysis window, available or not. The conservative rate: an item unavailable for a third of the window reads as a slower seller than it is. Stored whether or not it was applied, so a planner can see what the other policy would have produced without re-running.","name":"calendarDayRate","type":"decimal","required":true},{"info":"Units per case for this item at this vendor. Recommendations round up to whole cases; a null here is a data-quality exception, not a case size of one.","name":"caseQty","type":"integer"},{"info":"Quantity committed to open orders at this location.","name":"committedQty","type":"integer","required":true},{"info":"Weeks of cover the effective order quantity leaves, at the applied rate and reshaped by the curve. The line's own answer to 'and what does that buy us' — and the column that makes an overstock queue possible, since cover far above target is the definition of capital committed to cover nobody chose.","name":"coverWeeks","type":"decimal","required":true},{"info":"Which level of the assignment hierarchy supplied the curve. Provenance, not decoration: a line seasonally shaped by a classification average when the item has its own distinct pattern is a policy-coverage problem, and it is invisible unless the level is reported.","name":"curveSource","type":"enumeration","values":["ProductOverride","ClassificationOverride","DerivedFromItem","CompanyDefault"],"required":true},{"info":"Inbound quantity landing AFTER the end of the protection window — excluded from cover, shown separately, never silently netted. A long-lead line left short by a purchase order arriving weeks after the shelf empties is a specific, recurring failure, and it is invisible if late stock is either counted as cover or dropped without trace.","name":"excludedLateInboundQty","type":"integer","required":true},{"info":"Whether the rate came from this location's own history or from pooled locations rescaled to its volume. A borrowed rate is a legitimate answer for a new store and a weaker claim than a measured one; the line says which it is.","name":"historyBasis","type":"enumeration","values":["OwnHistory","PooledRescaled"],"required":true},{"info":"analysedSalesQty divided by analysedWindowCurveShare. The annualised demand the requirement is reshaped from, and the pivot of the whole calculation — it is what allows an October run to buy for December instead of buying another October.","name":"impliedAnnualDemandQty","type":"decimal","required":true},{"info":"Quantity allocated to this location from a distribution centre but not yet shipped.","name":"inboundAllocationQty","type":"integer","required":true},{"info":"In-transit transfer quantity destined for this location.","name":"inboundTransferQty","type":"integer","required":true},{"info":"Open purchase order quantity destined for this location and landing within the protection window. The netting that stops the same stock being ordered twice — the failure the PRD prices at $73.6k on a single representative run. NOTE: Item Stock's StockQuantities.incoming is one undated aggregate combining purchase orders and transfers, so this property, its transfer counterpart and the window gate cannot be populated from the current model. Blocking dependency, logged.","name":"incomingPurchaseOrderQty","type":"integer","required":true},{"info":"Units per day over only the days the item was sellable at this location. The true rate while in stock, and an over-buy when projected across a quarter for a chronically unavailable item. Requires a daily availability series reconstructed from Stock Ledger balances, which does not exist today — blocking dependency, logged.","name":"inStockDayRate","type":"decimal","required":true},{"info":"True once a person has typed into orderQty. THIS IS WHAT SEPARATES A TYPED ZERO FROM AN UNTOUCHED LINE. A typed zero is a decision not to buy — a judgement, made and recorded. An untouched line still tracks the model and moves when the policy changes. Without this flag the two are the same row, and the difference between 'leave it alone' and 'I looked at this and the answer is no' is lost.","name":"isAdjusted","type":"boolean","required":true},{"info":"The effective order quantity is not a whole multiple of caseQty. Only ever true on a typed quantity: recommendations round up. Flagged rather than corrected, because a buyer typing a part case may know something about the vendor that the case quantity does not.","name":"isPartCase","type":"boolean","required":true},{"info":"The item being planned. Denormalized snapshot carrying id, code and name, so a committed plan still reads correctly after the item is renamed or discontinued.","name":"item","type":"entityDetail","required":true,"relatedEntity":"Item"},{"info":"The last four retail weeks individually, most recent first — not a four-week total. A total hides the shape, and the shape is the point: 12/11/13/2 and 0/0/0/38 have the same sum and mean opposite things, one being a steady seller and the other a stockout with a delivery at the end of it.","name":"lastFourWeeksSalesQtys","type":"array","required":true},{"info":"Quoted vendor lead time in days for this item at this location. Quoted, not learned — measuring actual receipt lag from the Stock Ledger is deliberate future scope. Kept per line rather than read from the vendor at display time so a committed plan reflects the lead time it was planned against.","name":"leadTimeDays","type":"integer","required":true},{"info":"The location being planned for. Denormalized snapshot carrying id, code and name.","name":"location","type":"entityDetail","required":true,"relatedEntity":"Location"},{"info":"Maximum stock level in units, resolved from the governing Stock Limit Group. Null where no limit is configured, which is a normal state — limits are maintained for a subset of the assortment and the rest fall through to weeks of supply.","name":"maximumQty","type":"integer"},{"info":"Minimum stock level in units, resolved from the governing Stock Limit Group. Null where no limit is configured.","name":"minimumQty","type":"integer"},{"info":"Physical stock on hand at this location.","name":"onHandQty","type":"integer","required":true},{"info":"THE EFFECTIVE QUANTITY, and a three-state property. NULL means the line still tracks the model — suggestedQty applies and a policy change moves it. A TYPED VALUE pins the line at the buyer's number and it no longer follows the model. A TYPED ZERO is a decision not to buy, which is a judgement and must not be confused with an untouched line that happens to recommend nothing. Clearing the cell returns the line to the model. isAdjusted disambiguates typed-zero from null; collapsing the two into one field is the single most consequential modelling mistake available on this line.","name":"orderQty","type":"integer"},{"info":"Effective quantity times unitCost, in the plan's currency. Follows the buyer's number, not the model's, the moment a quantity is typed.","name":"orderValue","type":"decimal","required":true},{"info":"Proportion of the analysis window during which the item was not sellable at this location, 0 to 1. The figure that makes the divergence between the two rates interpretable — without it, a line where they differ by a factor of two looks like a bug rather than a stockout.","name":"outOfStockShare","type":"decimal","required":true},{"info":"on hand minus allocated minus committed plus window-gated incoming purchase orders plus inbound transfers plus inbound allocations, per the policy's inclusion flags. EVERY rule measures against this, never against raw on-hand. Stored as a total with all six terms beside it, because the total is what drives the quantity and the terms are what explain it.","name":"plannedOnHandQty","type":"integer","required":true},{"info":"Year-to-date units at the same point in the PRIOR retail year, resolved through the calendar's comparable-period mapping rather than by subtracting 52 weeks — after a 53-week year, ordinal matching shifts the comparison by seven days and moves a holiday out of its comparable week while every figure still looks plausible.","name":"priorYearToDateSalesQty","type":"decimal","required":true},{"info":"The applied curve's share of the annual year that the protection window covers. Multiplied by implied annual demand, this is the requirement — and it is where seasonality finally reaches the buy. On a holiday-profile item the prototype moved a 63-day September window's requirement from 89 to 99 units, and the divergence grows sharply approaching December.","name":"protectionWindowCurveShare","type":"decimal","required":true},{"info":"Lead time plus target weeks of supply, in days. The span the buy has to cover: everything from placing the order to the next delivery landing.","name":"protectionWindowDays","type":"integer","required":true},{"info":"planningDate plus protectionWindowDays. The gate for inbound: stock landing after this date is excluded from cover and reported in excludedLateInboundQty.","name":"protectionWindowEndDate","type":"date","required":true},{"info":"impliedAnnualDemandQty times protectionWindowCurveShare, plus safety stock where enabled. What the location needs to hold to cover the window, before anything it already has is deducted.","name":"requirementQty","type":"decimal","required":true},{"info":"The curve that shaped this line, as a denormalized snapshot carrying id, code and name. Named per line rather than only on the plan, because curve assignment resolves per item through the precedence hierarchy — two lines in one plan legitimately use different curves.","name":"salesCurve","type":"entityDetail","required":true,"relatedEntity":"Sales Curve"},{"info":"requirementQty minus plannedOnHandQty, floored at zero. The gap the order closes, before case rounding. Negative shortfall is overstock and is reported as zero here — the overstock signal is coverWeeks against target, not a negative order.","name":"shortfallQty","type":"decimal","required":true},{"info":"The group whose limits governed this line, resolved highest-priority-first where several cover the location. Named on the line because 'why is the maximum 40' is answerable only by knowing which group won — a national baseline or a flagship override. Null where no limit applies.","name":"stockLimitGroup","type":"entityDetail","relatedEntity":"Stock Limit Group"},{"info":"Whether limits applied at all. NoLimitSet is a normal and expected state for most of the assortment, and it is labelled rather than left blank so that 'no limit configured' is visibly different from 'limit of zero'.","name":"stockLimitSource","type":"enumeration","values":["GroupLimit","NoLimitSet"],"required":true},{"info":"What the model recommends, after the governing rule and case rounding. Never overwritten by a buyer's number — the two are stored side by side, which is what makes the adjustment rate measurable and the delta reviewable.","name":"suggestedQty","type":"integer","required":true},{"info":"Weeks of supply that applied to this line after the hierarchy resolved.","name":"targetWeeksOfSupply","type":"decimal","required":true},{"info":"Which level won. Company default, then classification, then product, then item. Reported so a planner can maintain the policy where it is actually set rather than overriding line by line — the difference between a policy that stays maintainable and one that decays into thousands of exceptions.","name":"targetWeeksOfSupplySource","type":"enumeration","values":["CompanyDefault","ClassificationOverride","ProductOverride","ItemOverride"],"required":true},{"info":"[RESTRICTED:cost] Unit cost used to value the line, in the plan's currency. Carries the cost restriction group in line with Item.currentUnitCost and its siblings: a planning surface must not become the way cost is read by someone barred from it on the product screen.","name":"unitCost","type":"decimal","required":true},{"info":"The vendor this line would be ordered from, as a denormalized snapshot carrying id, code and name. Null on a line served from a distribution centre, which becomes a transfer rather than a purchase.","name":"vendor","type":"entityDetail","relatedEntity":"Vendor"},{"info":"The scalar applied to pooled history to rescale it to this location's volume. Null when historyBasis is OwnHistory. Reported because a borrowed rate multiplied by an unstated factor is a number, not evidence.","name":"volumeIndexFactor","type":"decimal"},{"info":"Units sold at this Item x Location since the start of the current retail year.","name":"yearToDateSalesQty","type":"decimal","required":true}]},{"name":"ReplenishmentPlanScope","info":"What the run CONSIDERED, as distinct from what it recommended. Stored rather than inferred from the lines present, because an item that produced no line because it needed nothing and an item that was never in scope are indistinguishable afterwards — and they are opposite answers to the only question asked when a store runs out. All four filters intersect; an empty array means no restriction on that dimension.","properties":[{"info":"Classifications included. Empty means all. Classification is also the review cadence a merchandising director works in — categories that can run unattended against those that need a person.","name":"classifications","type":"array","required":true},{"info":"Reasons items within scope produced no line, with a count against each — no sales history, no cost, no lead time, no case quantity, discontinued, already covered. Empty array default. This is the exception queue: a data-quality problem that silently removes an item from a buy is the failure mode that looks like nothing happening.","name":"excludedLineReasons","type":"array","required":true},{"info":"Item Stock Groups included — the grouping that carries common stocking rules and replenishment strategy. Empty means all. Currently a two-property stub in the registry with no schema behind it.","name":"itemStockGroups","type":"array","required":true},{"info":"Locations the run planned for. Each entry is a Location reference. Empty means every active stocking location in the tenant, which is a legitimate choice for a small estate and an unreviewable one for a large estate.","name":"locations","type":"array","required":true},{"info":"Vendors included. Empty means all. Scoping a run to one vendor is the normal way to work a buy that has to clear a minimum order value.","name":"vendors","type":"array","required":true}]},{"name":"ReplenishmentPlanStatus","info":"Lifecycle of the run, with the audit stamp of who moved it. Modelled as an inline schema on the Retail Calendar and Sales Curve precedent rather than as a flat enumeration: committing a plan raises orders against a vendor and commits working capital, which is an accountable act and is attributed to a person.","properties":[{"info":"The User who last changed the status. Computation is a background job; review and commit are not.","name":"changedBy","type":"string"},{"info":"When the status last changed. UTC.","name":"changedDate","type":"datetime"},{"info":"Why a run did not complete — missing sales history, an unresolvable calendar, a curve that does not reconcile. Populated only in Failed. Retained after a successful re-run rather than cleared, because a run that failed twice for the same reason is worth being able to find.","name":"failureReason","type":"string"},{"info":"Draft: computing, or computed and not yet opened. InReview: a planner is working the lines; order quantities may be typed and the policy may be re-run over the same scope. Committed: documents raised; the plan and every line are frozen. Cancelled: abandoned without raising documents — a legitimate and informative outcome, kept rather than deleted. Failed: computation did not complete; see failureReason. A committed plan is never reopened; a change of mind is a new plan, because the orders it raised already exist at a vendor.","name":"status","type":"enumeration","values":["Draft","InReview","Committed","Cancelled","Failed"],"required":true}]},{"name":"ReplenishmentPlanDocument","info":"One purchase or transfer order the plan raised, with the vendor-minimum check that was applied to it. Written at commit and never edited afterwards — the documents then live their own lifecycles, and this records what the plan raised, not what subsequently happened to it. The set exists so that the question a buyer asks before committing — how many documents does my selection become, and do they clear their minimums — is answerable from the plan rather than from the order tables.","properties":[{"info":"When the document was raised. UTC.","name":"createdAt","type":"datetime"},{"info":"Where the stock is going — a store under direct-to-store, or the distribution centre under consolidation. Denormalized snapshot carrying id, code and name.","name":"destinationLocation","type":"entityDetail","required":true,"relatedEntity":"Location"},{"info":"Which document was raised. Items served from a distribution centre become transfer orders under either consolidation mode — except where the DC cannot cover the requirement, which reverts to a vendor order. Fair-sharing constrained DC stock across stores is deliberately out of scope: v1 detects the shortfall and reroutes.","name":"documentType","type":"enumeration","values":["PurchaseOrder","TransferOrder"],"required":true},{"info":"Plan lines rolled into this document.","name":"lineCount","type":"integer","required":true},{"info":"The document's value clears the vendor's minimum order value. Evaluated and surfaced BEFORE commit with the consolidation remedy named, because a purchase order discovered to be below minimum at the vendor is a phone call and a delay, not a validation error. Always true for a transfer order, which has no vendor minimum.","name":"meetsVendorMinimum","type":"boolean","required":true},{"info":"Total units on the document.","name":"orderQty","type":"integer","required":true},{"info":"Total value of the document at unit cost, in the plan's currency. The figure compared against the vendor minimum.","name":"orderValue","type":"decimal","required":true},{"info":"The Purchase Order that was raised. Null on a transfer line.","name":"purchaseOrder","type":"entityRef","relatedEntity":"Purchase Order"},{"info":"The distribution centre a transfer draws from. Null on a purchase order. Denormalized snapshot carrying id, code and name.","name":"sourceLocation","type":"entityDetail","relatedEntity":"Location"},{"info":"The Transfer Order that was raised. Null on a purchase line.","name":"transferOrder","type":"entityRef","relatedEntity":"Transfer Order"},{"info":"The vendor ordered from. Null on a transfer order. Denormalized snapshot carrying id, code and name.","name":"vendor","type":"entityDetail","relatedEntity":"Vendor"},{"info":"The vendor minimum in force when the check ran, snapshotted so a later change to the vendor record does not rewrite whether a past order cleared it. Null where the vendor sets none.","name":"vendorMinimumOrderValue","type":"decimal"}]},{"name":"ReplenishmentAnalysisWindow","info":"The span of history the sales rate was measured over, and whose history it was. The rate is the foundation of every quantity in the plan, so the window that produced it is stated on the plan and echoed on every line. Two runs on the same day over the same estate with different windows are two different, equally defensible answers — which is only true if each says which window it used.","properties":[{"info":"Inclusive end of the analysed span. Resolved to a retail week boundary wherever the mode allows it: a window that ends mid-week picks up an unbalanced mix of weekdays, and on the FY2025 client shape the difference between a window ending Saturday and one ending Sunday is 1.49 average days against 0.82.","name":"endDate","type":"date","required":true},{"info":"Length of the window in retail weeks — typically 4, 8, 13, 26 or 52. Short windows follow a trend and chase noise; long ones are stable and slow to notice that something has changed. The choice is a judgement about the assortment, not a default worth hiding.","name":"lengthWeeks","type":"integer","required":true},{"info":"Whose history the rate came from. OwnHistory is correct whenever a location has enough of its own. AllStores and SelectedLocations pool other locations' history and rescale it to this location's volume index — the answer for a new store or one that was stocked out for months, and a borrowed shape that must be labelled as borrowed on every line that used it.","name":"locationScope","type":"enumeration","values":["OwnHistory","AllStores","SelectedLocations"],"required":true},{"info":"How the window was selected. TrailingWeeks reads the most recent N weeks. SamePeriodLastYear reads the comparable retail weeks a year ago, which is the right basis for a seasonal peak the trailing window has not reached yet, and which depends on the calendar's priorYearPeriod mapping rather than on subtracting 52 weeks — after a 53-week year, ordinal matching silently shifts every comparison by seven days. CustomWeekRange is an explicit span, used when a planner knows a period was unrepresentative.","name":"mode","type":"enumeration","values":["TrailingWeeks","SamePeriodLastYear","CustomWeekRange"],"required":true},{"info":"Scale pooled history by the target location's volume index. A store indexing 2.1 against the pooled average takes a factor of about 1.82 on the estate the prototype was built against; the line reports the factor it used, because a borrowed rate multiplied by an unstated scalar is not evidence.","name":"rescaleToVolumeIndex","type":"boolean","required":true},{"info":"Locations whose history was pooled, when locationScope is SelectedLocations. Each entry is a Location reference. Empty array default under the other two modes.","name":"scopeLocations","type":"array","required":true},{"info":"Inclusive start of the analysed span.","name":"startDate","type":"date","required":true}]},{"name":"ReplenishmentPolicySnapshot","info":"The settings that governed this run, copied in at run time. A snapshot rather than a reference to the live Settings record: policy is tuned continuously, and a plan that resolves its policy at read time reports today's settings as the basis of an order raised six weeks ago. Every flag here changes a quantity, so every flag is part of the explanation.","properties":[{"info":"How the selection becomes documents. DirectToStore raises one purchase order per vendor per store — minimal handling, and the mode that fails vendor minimums. ConsolidateToDistributionCentre raises one order per vendor for the chain, delivered to the DC, plus transfer orders allocating it out. The trade is handling and freight cost against clearing minimums, and it is a per-run decision rather than a standing one.","name":"consolidationMode","type":"enumeration","values":["DirectToStore","ConsolidateToDistributionCentre"],"required":true},{"info":"Whether the requirement is built from measured history reshaped by the curve, or from a statistical forecast a planner may have overridden. AnalysedSales needs no forecaster, which is what allows the buy recommendation to ship before one exists. ForecastPlan is the Phase 2 hook and is stated here so a plan run against a forecast is distinguishable from one run against history — they are different claims about the future.","name":"demandBasis","type":"enumeration","values":["AnalysedSales","ForecastPlan"],"required":true},{"info":"Deduct stock that is physically present but promised to an outbound transfer or a sales order. On by default in intent: stock allocated away is not available to sell, and counting it as cover is how a store shows healthy on-hand and empties the following week.","name":"includeAllocated","type":"boolean","required":true},{"info":"Deduct quantity committed to open orders.","name":"includeCommitted","type":"boolean","required":true},{"info":"Count stock allocated to this location from a distribution centre but not yet shipped as cover.","name":"includeInboundAllocations","type":"boolean","required":true},{"info":"Count in-transit transfer quantity as cover.","name":"includeInboundTransfers","type":"boolean","required":true},{"info":"Net off quantity already on open purchase orders. Turning this off is the single most expensive mistake the module can make: in the prototype's representative estate it over-bought by $73.6k on one run — 14,426 units becoming 19,058 — by ordering stock that was already on its way.","name":"includeIncomingPurchaseOrders","type":"boolean","required":true},{"info":"Which of the three replenishment models produced the quantities. SalesAndTargetWeeksOfSupply annualises the analysed sales against the curve and reshapes onto lead time plus target weeks of supply. StockLimit fills to maximum when at or below minimum and does nothing otherwise. Combined runs the first and then holds it inside the limits — capped at maximum above, filled to maximum below minimum. Combined has a known behaviour worth deciding on before pilot: a slow mover whose weeks-of-supply requirement lands below minimum is filled to MAXIMUM, so the limit values carry the whole tail. Implemented as specified and reported per line through appliedRule rather than quietly softened.","name":"method","type":"enumeration","values":["SalesAndTargetWeeksOfSupply","StockLimit","Combined"],"required":true},{"info":"Which sales rate drives the buy. CalendarDays divides sales by every day in the window, so an item that was unavailable for a third of it reads as a slower seller — conservative, and the prototype default. InStockDaysOnly divides by the days it was actually sellable, which is the true rate while in stock and over-buys when projected across a quarter for a chronically unavailable item: roughly 8% across the estate in the prototype, with worst lines doubling — one line 41% out of stock moving 18 units to 66. Neither is right in general, which is why it is a stated policy and both rates are stored on every line.","name":"outOfStockTreatment","type":"enumeration","values":["CalendarDays","InStockDaysOnly"],"required":true},{"info":"Round recommendations up to whole cases. A 6-per-case item never recommends 7. Manually typed quantities are accepted as typed and flagged as part cases rather than silently rounded — a buyer who types 7 may know something about the vendor that the case quantity does not.","name":"roundToWholeCases","type":"boolean","required":true},{"info":"Add statistical safety stock on top of the target. Off by default and deliberately so: a weeks-of-supply target already carries an implicit buffer, and adding a second one on top of it double-counts the protection while looking like rigour. Meaningful only once forecast error is measured.","name":"safetyStockEnabled","type":"boolean","required":true},{"info":"The company-level default in force at run time, in weeks. The bottom of the resolution hierarchy: classification and product overrides supersede it, and each line reports which level actually applied. Snapshotting the default matters because it is the value most often tuned between runs.","name":"targetWeeksOfSupply","type":"decimal","required":true},{"info":"Count only inbound landing on or before the end of the protection window as cover. Stock arriving after the window it would need to cover is excluded and shown separately on the line. Without this gate a long-lead line is left short by a purchase order that arrives weeks after the shelf empties — and the line looks covered right up until it does not.","name":"windowGateInbound","type":"boolean","required":true}]}]},{"name":"Report","class":"Core","subsystem":"CONNECT","area":"Analytics","desc":"A configured analytics view — either internal or via Looker — categorized by domain.","status":"draft","properties":[{"n":"code","r":true,"t":"string","u":true,"info":"Human-readable key. Unique within scope."},{"n":"name","r":true,"t":"string","u":true}],"ext":"BaseDocument","shopify":"Reports API","bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Retail Calendar","class":"Dictionary","subsystem":"CONNECT","area":"Finance","desc":"The definition of a tenant's fiscal calendar and the rule that generates it. A retail calendar divides the year into weeks that always start on the same weekday, so that every fiscal period contains a whole number of weeks and the same count of each trading day — which is what makes week-over-week and year-over-year comparison meaningful in a business whose sales depend on which day of the week it is. A calendar month does not have that property: February 2026 holds four Saturdays and March holds five, so a month-over-month sales comparison is partly a comparison of how many weekends each month happened to contain.\n\nThis entity holds the DEFINITION ONLY — pattern, week start, year-end anchor rule, year labelling rule, and how far ahead periods are generated. The generated periods themselves are Retail Calendar Period rows, one per grain per fiscal year. The split is deliberate: the definition is a handful of values a person authors once, while the periods are thousands of machine-written rows that documents, ledger entries and reports reference individually.\n\nSupported patterns are 4-5-4 (the NRF standard: quarters of 13 weeks split into periods of 4, 5 and 4 weeks), 4-4-5 and 5-4-4 (the same 13-week quarter with the long period placed differently), and Gregorian calendar months as a pass-through so that a tenant reporting on calendar months resolves fiscal dates through the same entity rather than through a null-calendar special case.\n\nTHE DEFINITION IS IMMUTABLE ONCE ACTIVE. Changing an anchor rule after periods exist re-dates every period, and every Sale, Stock Ledger entry and Financial Summary already attributed to a period silently moves to a different one — with no error anywhere, because both the old and the new attribution are valid dates in valid periods. A different calendar is a new Retail Calendar, not an edit to this one.","status":"draft","properties":[{"n":"calendarType","r":true,"t":"enumeration","v":["FourFiveFour","FourFourFive","FiveFourFour","GregorianMonth"],"info":"The period pattern within each quarter. FourFiveFour is the NRF standard used by most US specialty retail: a 13-week quarter split 4 + 5 + 4. FourFourFive and FiveFourFour are the same 13-week quarter with the five-week period placed first or last — common in wholesale and outside the US. GregorianMonth is a pass-through for tenants that report on calendar months; it produces Year, Quarter and Period rows on calendar-month boundaries and no Week rows, because weeks do not nest inside calendar months. All four supported patterns yield 12 periods and 4 quarters per year, so periodsPerYear is derived rather than declared. A 13-period (thirteen equal 4-week periods) calendar is deliberately NOT supported — see the open question."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Required per tenant-scoped-dictionary-declares-company: a fiscal calendar is that tenant's own accounting decision, not a fact about the world, and two Companies sharing an Organization may legitimately close on different weekdays."},{"n":"firstFiscalYear","r":true,"t":"integer","info":"The earliest fiscal year for which Retail Calendar Period rows are generated, labelled per fiscalYearLabelRule. Bounds the calendar backwards: history older than this has no period to resolve into, which is a deliberate and visible failure rather than a silent fallback to calendar months. Set it at or before the earliest transaction the tenant will migrate."},{"n":"fiscalYearLabelRule","r":true,"t":"enumeration","v":["StartYear","EndYear"],"info":"Which Gregorian year names a fiscal year that spans two of them. NRF fiscal 2026 begins in February 2026 and ends in January 2027 and is labelled by its START year; many accounting calendars label the same span by its END year. Required and never defaulted, because getting it wrong does not break anything — it renames every fiscal year in every report by one, and the figures underneath stay correct, so the error survives review and surfaces only when someone compares a platform report against the ERP."},{"n":"generatedThroughFiscalYear","t":"integer","info":"High-water mark of generation: the last fiscal year for which Retail Calendar Period rows exist. Null until the calendar is first generated. Read together with generationHorizonYears to tell whether the generator is keeping up — a calendar whose horizon has been reached stops resolving future dates, and open-to-buy and allocation planning run against future periods."},{"n":"generationHorizonYears","r":true,"t":"integer","info":"How many fiscal years beyond the current one the generator maintains. Three is a sensible default: merchandise planning and open-to-buy routinely reference the next fiscal year, and a horizon of one leaves planners without periods to plan into during the final quarter. Extending the horizon is always safe; it appends future rows and touches nothing already generated."},{"n":"lastGeneratedAt","t":"datetime","info":"When the generator last wrote periods for this calendar. UTC. Null before first generation."},{"n":"leapWeekPlacement","r":true,"t":"enumeration","v":["FinalPeriod","FinalQuarterFirstPeriod","NotApplicable"],"info":"Where the 53rd week goes in a 371-day fiscal year. FinalPeriod appends it to the last period of the year, which is the NRF convention and makes that period five weeks (a 4-5-4 year ending 4-5-5) and its quarter 14 weeks. FinalQuarterFirstPeriod places it at the start of the final quarter instead. NotApplicable for GregorianMonth, which has no leap week. Which period absorbs the extra week decides which period's sales, margin and payroll figures are inflated by roughly 20% — so it must be stated, not inferred, and it must match whatever the tenant's ERP already does."},{"n":"status","r":true,"t":"schema","info":"RetailCalendarStatus inline schema. Draft while the definition is being authored and no periods exist; Active once periods are generated, at which point the definition properties become immutable; Archived when superseded. Distinct from the inherited isActive flag, which controls whether the calendar appears in selection lists — an Archived calendar is still referenced by every document ever posted against it and must never be deleted."},{"n":"weekStartDay","r":true,"t":"enumeration","v":["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],"info":"The weekday every fiscal week begins on. Sunday for the NRF calendar, so fiscal weeks run Sunday through Saturday and a weekend falls wholly inside one week rather than being split across two. The fiscal year therefore always ends on the day before this one; that year-end weekday is derived and deliberately not stored, because two homes for one concept is how a Sunday-start calendar ends up with a Friday year-end in one report."},{"n":"yearEndAnchorMonth","r":true,"t":"integer","info":"The Gregorian month (1-12) whose end the fiscal year is anchored to. January (1) for the NRF calendar, which places year end after the post-holiday clearance and return period rather than in the middle of it. Combined with yearEndAnchorRule and weekStartDay, this fixes every date in the calendar."},{"n":"yearEndAnchorRule","r":true,"t":"enumeration","v":["LastWeekdayOfMonth","WeekdayNearestMonthEnd","NotApplicable"],"info":"How the fiscal year end is located within yearEndAnchorMonth. WeekdayNearestMonthEnd is the NRF rule — the Saturday nearest 31 January, which may fall in early February — and it is what produces a 53-week year roughly every five or six years. LastWeekdayOfMonth keeps year end inside the month, and produces the 53rd week on a different cadence. NotApplicable for GregorianMonth. The two rules disagree about which year the extra week falls in, so a calendar rebuilt under the wrong one is off by a week for years at a time rather than obviously broken."}],"ext":"LookupEntity","notes":"PROPOSED — not yet reviewed. Created 03 Sep 2026 in response to \"add a retailcalendar entity to platform explorer\". A registry search for `calendar` returned one unrelated entity (Calendar Event, a meeting/scheduling document in Messaging) and the glossary returned nothing for `calendar`, `fiscal` or `period`. Five entities already carry a `fiscalDate` or `fiscalPeriod` property — Sale, Stock Adjustment, Stock Transfer, Goods Receipt and Financial Summary — and until now nothing in the registry defined what a fiscal period IS.\n\nSCOPE: two entities. Retail Calendar holds the definition; Retail Calendar Period holds the generated rows. The alternative — one entity with periods as an inline schema array — was rejected because periods must be individually referenceable: Financial Summary posts against one, analytics joins a date dimension to them, and a week needs to point at its comparable week last year. An inline array supports none of that.\n\nWHY Dictionary / LookupEntity. A calendar is selected by reference from a dropdown, retired without deletion, and has exactly one tenant default — which is `code`, `isActive` and `isDefault`, the LookupEntity shape, unchanged. The cost of this choice is that LookupEntity provides `idmpKey` but not `identifiers`, so there is nowhere to record the ERP's own identifier for the same calendar. That matters for Financial Summary posting and is logged as an open question rather than papered over.\n\nWHY Finance. The forcing consumer is the period close. Merchandising planning and comp reporting use the calendar just as heavily, and Analytics or a new Planning area would also be defensible; Finance was chosen because Financial Summary lives there and fiscal attribution is what the entity is for.\n\nDELIBERATE OMISSIONS:\n- No `timeZone`. Period attribution needs one, and Location.timezone (falling back to Company.timezone) already provides it. A calendar spanning an estate in several timezones cannot honestly carry one.\n- No `periodsPerYear`, `quarterPattern` or `yearEndWeekday`. All three are derivable from calendarType and weekStartDay, and a stored copy that disagrees with the rule is worse than a computed one.\n- No trading-day boundary. The calendar maps a DATE to a period; it does not decide which date a 01:00 transaction belongs to. Location Traffic already models this as `businessDate`; nothing defines it centrally. Open question.\n- No 13-period (thirteen 4-week periods) support. Standard in grocery and food service, out of scope by decision here. Adding it would break the derived periodsPerYear = 12 and needs a `periodsPerYear` property plus a quarter model that admits no quarters.\n\nMIGRATION IS CHEAP IF THE DICTIONARY CHOICE PROVES WRONG. Rebasing onto OperationalDocument adds `identifiers`, `notes`, `tags` and `franchiseGroups` and removes `isReadOnly`, `alias` and `sequence`; every property declared here survives unchanged. Nothing modelled today has to be unpicked.","related":["Retail Calendar Period","Company","Location","Financial Summary","Sale","Stock Transfer","Stock Adjustment","Goods Receipt"],"bv":{"rules":[{"rule":"code must be unique within the Company","when":"create","field":"Code","severity":"error"},{"rule":"At most one Retail Calendar per Company may carry isDefault = true. The default is the calendar that resolves a fiscalDate when no calendar is named explicitly","when":"always","field":"IsDefault","severity":"error"},{"rule":"IMMUTABLE ONCE ACTIVE. calendarType, weekStartDay, yearEndAnchorMonth, yearEndAnchorRule, fiscalYearLabelRule, firstFiscalYear and leapWeekPlacement may not change once status.status = 'Active'. Editing any of them re-dates every generated period, and every Sale, Stock Ledger entry and Financial Summary already attributed to a period moves silently to a different one — both attributions being valid dates in valid periods, nothing errors. A different calendar is a new Retail Calendar","when":"update","field":"CalendarType","severity":"error"},{"rule":"yearEndAnchorMonth must be between 1 and 12","when":"always","field":"YearEndAnchorMonth","severity":"error"},{"rule":"generationHorizonYears must be at least 1","when":"always","field":"GenerationHorizonYears","severity":"error"},{"rule":"generatedThroughFiscalYear, when set, must be greater than or equal to firstFiscalYear","when":"always","field":"GeneratedThroughFiscalYear","severity":"error"},{"rule":"While status.status = 'Active', generatedThroughFiscalYear must be at least the current fiscal year plus one. A calendar whose horizon has been reached stops resolving future dates, and open-to-buy, allocation and labor planning all run against future periods — the failure appears as a planning screen with no periods in it rather than as an error","when":"always","field":"GeneratedThroughFiscalYear","severity":"warning"},{"rule":"When calendarType = 'GregorianMonth', yearEndAnchorRule and leapWeekPlacement must both be 'NotApplicable', and no Week-grain Retail Calendar Period rows are generated — weeks do not nest inside calendar months","when":"always","field":"CalendarType","severity":"error"},{"rule":"When calendarType is not 'GregorianMonth', neither yearEndAnchorRule nor leapWeekPlacement may be 'NotApplicable'","when":"always","field":"CalendarType","severity":"error"},{"rule":"A calendar referenced by any Closed or Posted Financial Summary may be Archived but never soft-deleted. Deleting it orphans the fiscal attribution of documents that have already been accepted by an external general ledger","when":"delete","field":"IsDeleted","severity":"error"},{"rule":"Moving to Active requires that Retail Calendar Period rows exist for every fiscal year from firstFiscalYear through generatedThroughFiscalYear, at every grain the calendarType produces. A partially generated Active calendar resolves some dates and not others","when":"update","field":"Status","severity":"error"}],"lifecycle":{"states":["Draft","Active","Archived"],"transitions":[{"to":"Active","from":"Draft","conditions":["Definition complete and internally consistent for the chosen calendarType","Periods generated for firstFiscalYear through the horizon","changedBy and changedDate recorded"]},{"to":"Archived","from":"Draft","conditions":["Draft abandoned before any period was generated"]},{"to":"Archived","from":"Active","conditions":["Superseded by another calendar","No new documents may be attributed to it; existing references retained"]}],"initialState":"Draft"},"calculations":[{"info":"Derived rather than declared. All four supported patterns yield 12 periods and 4 quarters. A 13-period calendar would break this and is out of scope by decision.","name":"periodsPerYear","formula":"12 for every supported calendarType","trigger":"constant"},{"info":"Derived, never stored. Storing it alongside weekStartDay gives one concept two homes, which is how a Sunday-start calendar ends up reporting a Friday year end.","name":"yearEndWeekday","formula":"the weekday immediately preceding weekStartDay","trigger":"query-time"},{"info":"Materialized onto the Year-grain Retail Calendar Period row rather than recomputed, because it is the denominator of every year-over-year per-week comparison.","name":"weekCountForFiscalYear","formula":"53 when the anchor rule places 371 days between consecutive year ends, otherwise 52","trigger":"On generation"},{"name":"isLeapWeekYear","formula":"weekCountForFiscalYear = 53","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"A Company may hold more than one Retail Calendar — a merchandising calendar and an accounting calendar do not always agree — but at most one may be isDefault. Whether the two roles need to be named explicitly rather than resolved by a single default flag is an open question","entity":"Company"},{"rule":"Financial Summary.fiscalPeriod is a free-text label today, by an explicit earlier decision that the fiscal calendar 'is defined in the accounting system rather than here'. This entity makes that no longer true. Where a Retail Calendar exists, fiscalPeriod should resolve to a Retail Calendar Period code rather than being typed by hand — logged as an open question rather than changed unilaterally","entity":"Financial Summary"},{"rule":"Sale.fiscalDate resolves to exactly one Retail Calendar Period per grain. The resolution is performed in the Location's timezone, falling back to Company.timezone; this entity carries no timezone of its own, deliberately, because a calendar spanning an estate in several timezones would otherwise claim one","entity":"Sale"},{"rule":"The calendar defines which period a calendar DATE falls in. It does not define which date a transaction belongs to — a store trading past midnight attributes its 01:00 sales to the prior trading day, and nothing in the registry currently defines that boundary. Logged as an open question","entity":"Location"},{"rule":"fiscalDate on Stock Transfer, Stock Adjustment and Goods Receipt resolves through the same calendar, so that inventory movement and revenue land in the same period. Resolving them through different calendars is how a period closes with COGS and sales out of step","entity":"Stock Transfer"}]},"inlineSchemas":[{"name":"RetailCalendarStatus","info":"Authoring lifecycle of the calendar definition, with the audit stamp of who moved it. Modelled as an inline schema rather than a flat enumeration on the CompanyStatus precedent: activating a calendar freezes its definition permanently, which is an accountable act and worth attributing to a person.","properties":[{"info":"Draft: definition editable, no periods generated. Active: periods generated and referenced; the definition properties are immutable from here on and the generator extends the horizon forward only. Archived: superseded by another calendar; no new documents may be attributed to it, and every existing reference is retained.","name":"status","type":"enumeration","values":["Draft","Active","Archived"],"required":true},{"info":"The User who last changed the status. Activating a calendar is a deliberate act, not a background job.","name":"changedBy","type":"string"},{"info":"When the status last changed. UTC.","name":"changedDate","type":"datetime"}]}]},{"name":"Retail Calendar Period","class":"Dictionary","subsystem":"CONNECT","area":"Finance","desc":"One generated span of a Retail Calendar at one grain — a fiscal Year, Quarter, Period or Week — with its inclusive calendar date range, its place in the hierarchy, and the period it compares against last year. These rows are what documents actually reference: a Financial Summary posts against one, a Sale's fiscalDate resolves into one per grain, and every year-over-year report joins through one.\n\nRows are written only by the calendar's generator, from the definition on Retail Calendar. Nothing outside that generator writes them, which is why the entity extends Identifiable directly rather than an authoring base — the same reasoning that puts Inventory Position and Stock Cost Layer on Identifiable. There is no idempotency key and no external identifier because there is no external write path.\n\nTHE MOST IMPORTANT PROPERTY IS priorYearPeriod. A 53-week year breaks ordinal matching: after one, week 14 of the new year lines up with week 15 of the prior year, and \"same week number last year\" silently shifts an entire year of comparisons by seven days — moving a holiday, a promotion or a clearance event out of its comparable week while every figure still looks plausible. Recording the comparable period once, at generation, is the difference between one considered decision and the same decision re-derived differently in every report, dashboard and extract.\n\nVolume is small: roughly 70 rows per fiscal year per calendar (52 or 53 weeks, 12 periods, 4 quarters, 1 year). A decade of history and a three-year horizon is under a thousand rows.","status":"draft","properties":[{"n":"code","r":true,"t":"string","u":true,"info":"Stable human-readable label for the period, unique within Company, per dictionary-code-property. Composed from the calendar code, fiscal year and grain-specific number — for example NRF454-2026, NRF454-2026-Q1, NRF454-2026-P02, NRF454-2026-W14. This is the value that belongs in an export, an ERP mapping or a Financial Summary's fiscalPeriod, because it survives regeneration and means the same thing to a person as to a machine."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — hard isolation boundary for queries and replication. Declared directly because this entity extends Identifiable, which provides no tenant scoping; the same reason Inventory Position and Stock Cost Layer declare it. (An earlier note here generalized that to 'no base schema in the registry provides tenant scoping', following the 01 Sep 2026 audit. That was withdrawn on 03 Sep 2026 — OperationalDocument, BaseDocument, LedgerEntry, TaxonomyEntity and TaxonomyEntityNode all provide it, and LookupEntity provides it optional.)"},{"n":"endDate","r":true,"t":"date","info":"Last calendar date of the period, INCLUSIVE. Inclusive rather than exclusive, unlike Financial Summary.periodEnd, and the difference is deliberate: this is a bare calendar date with no time component, so there is no boundary instant to land on the wrong side of, and inclusive is how every retailer reads and publishes a fiscal calendar. A consumer converting to a datetime range must use [startDate 00:00 local, endDate + 1 day 00:00 local) — stated here because silently treating an inclusive endDate as exclusive drops the last trading day of every period, which is a Saturday, the biggest day of the week."},{"n":"fiscalYear","r":true,"t":"integer","info":"The fiscal year this period belongs to, labelled according to the calendar's fiscalYearLabelRule. Under the NRF StartYear rule, the week beginning 25 January 2027 belongs to fiscal year 2026."},{"n":"generatedAt","r":true,"t":"datetime","info":"When the generator wrote this row. UTC. The only audit field on the entity: rows are machine-written and never edited by a person, so createdBy / modifiedBy would record the generator on every one of them and tell nobody anything."},{"n":"grain","r":true,"t":"enumeration","v":["Year","Quarter","Period","Week"],"info":"The level of the calendar hierarchy this row represents. Rows of different grains legitimately overlap — a Quarter contains its Periods — so the tiling and uniqueness rules are scoped to one grain at a time. Week rows are not generated for a GregorianMonth calendar, because weeks do not nest inside calendar months."},{"n":"parentPeriod","t":"entityRef","re":"Retail Calendar Period","info":"The containing period at the next coarser grain: a Week's Period, a Period's Quarter, a Quarter's Year. Null only at grain = Year. Materialized so a rollup is a key join rather than a date-range overlap test, which is both slower and quietly wrong at the boundaries in a 53-week year. Modelled as entityRef rather than entityDetail, a deliberate departure from dictionary-ref-uses-entity-detail: denormalizing a snapshot of the parent into every one of its child rows means a regenerated horizon leaves stale copies behind, and a self-referential entityDetail has no natural depth limit."},{"n":"periodNumber","r":true,"t":"integer","info":"Ordinal position within the fiscal year at this grain: 1-53 for Week, 1-12 for Period, 1-4 for Quarter, always 1 for Year. Together with retailCalendar, grain and fiscalYear this is the natural key. Note that this number is NOT a safe year-over-year join key on its own — that is what priorYearPeriod exists for."},{"n":"priorYearPeriod","t":"entityRef","re":"Retail Calendar Period","info":"The period this one compares against for like-for-like reporting — the comparable week, period, quarter or year. Set by the generator, not derived at query time, because ordinal matching is wrong across a 53-week year: after one, week N aligns to week N+1 of the prior year, and matching on periodNumber shifts a whole year of comparisons by seven days, moving holidays and promotional events out of their comparable week while every number still looks reasonable. Null for the calendar's firstFiscalYear, which has nothing to compare against. Where the tenant's convention differs from the platform default, this is the single place to override it."},{"n":"retailCalendar","r":true,"t":"entityDetail","re":"Retail Calendar","info":"The calendar that generated this period. entityDetail per dictionary-ref-uses-entity-detail — the denormalized code and name travel with the row into reports and extracts, where resolving the calendar separately would mean a join per row."},{"n":"startDate","r":true,"t":"date","info":"First calendar date of the period, INCLUSIVE. For a non-Gregorian calendar this always falls on the calendar's weekStartDay."},{"n":"weekCount","r":true,"t":"integer","info":"Number of fiscal weeks the period spans: 1 at Week grain; 4 or 5 at Period grain (5 or 6 for the period absorbing a leap week under FinalPeriod placement); 13 or 14 at Quarter grain; 52 or 53 at Year grain. Materialized because it is the denominator of every per-week average and every normalized year-over-year comparison — without it, a 53-week year reads as 2% growth that never happened, and the period holding the extra week reads as a 25% jump. Deriving it from the date range would re-implement calendar arithmetic in every consumer, Looker included."}],"ext":"Identifiable","notes":"PROPOSED — not yet reviewed. Created 03 Sep 2026 alongside Retail Calendar.\n\nWHY Identifiable RATHER THAN AN AUTHORING BASE. Rows are written only by the calendar generator. There is no external write path, so `idmpKey` and `identifiers` have nothing to hold; there is no human author, so `createdBy` / `modifiedBy` would record the generator on every row; there is no per-row visibility question, so `franchiseGroups` is meaningless — a calendar is company-wide by construction. This follows Inventory Position and Stock Cost Layer, which extend Identifiable directly for the same reason. `company` and `code` are declared directly to satisfy company-scoping-required and dictionary-code-property, and `generatedAt` is the one audit field that carries information.\n\nKNOWN CONVENTION DEPARTURE. `parentPeriod` and `priorYearPeriod` are entityRef, not entityDetail, and the dictionary-ref-uses-entity-detail convention (severity: warning) points at entityDetail because the target is a Dictionary-class entity. The departure is deliberate and self-referential: denormalizing a snapshot of one calendar row into every row that points at it leaves stale copies behind whenever the horizon is regenerated, and a self-referential entityDetail chain has no natural depth limit. `retailCalendar` does use entityDetail, where the denormalization is genuinely useful and the target is stable. Flagging this here so it reads as a decision rather than an oversight.\n\nINCLUSIVE endDate IS THE ONE PLACE THIS ENTITY DISAGREES WITH ITS NEIGHBOURS. Financial Summary.periodEnd is an EXCLUSIVE datetime, chosen so consecutive periods abut without a boundary transaction landing in both or neither. Here the type is `date` — a bare calendar date with no time component, per date-is-calendar-only — so there is no boundary instant, and inclusive is how every published retail calendar reads. The conversion rule is stated on the property and in the validation rules because getting it wrong drops the last day of every period, which is always a Saturday.\n\nWHAT THIS ENTITY DOES NOT DO:\n- No `isCurrent`, `isClosed` or `isOpen` flag. Currency of a period is a function of today's date and a timezone; a stored flag needs a daily job per timezone and is believed when stale. Period CLOSE state belongs to Financial Summary, which already has a five-state posting lifecycle — putting a second close flag here would give one concept two homes.\n- No `name`. Derivable from grain, fiscalYear and periodNumber, and a generated English label on every row is a translation problem.\n- No 4-5-4 month-name mapping. Presentation.\n- No planning figures — no budget, plan or last-year sales. This is a date dimension, not a plan.\n\nGENERATION IS THE PART THAT ISN'T MODELLED. The rules are all stated as validation, but nothing here says which service runs the generator, on what schedule, or what happens when the horizon lapses. That likely belongs to an architecture-layer node and a Data Flow, neither of which was added.","related":["Retail Calendar","Company","Location","Financial Summary","Sale","Stock Ledger"],"bv":{"rules":[{"rule":"The tuple (retailCalendar, grain, fiscalYear, periodNumber) is unique. This is the natural key; code is the human-readable form of the same key","when":"always","field":"PeriodNumber","severity":"error"},{"rule":"endDate must be greater than or equal to startDate. Both are INCLUSIVE calendar dates with no time component","when":"always","field":"EndDate","severity":"error"},{"rule":"Periods of the same calendar and the same grain must tile the calendar exactly: for consecutive rows, previous.endDate + 1 day = next.startDate. A gap leaves a trading date that resolves to no period and whose sales appear in no report; an overlap counts every transaction in it twice. Rows of DIFFERENT grains overlap freely — a Quarter contains its Periods","when":"always","field":"StartDate","severity":"error"},{"rule":"For a calendar whose calendarType is not GregorianMonth, every Week-grain row spans exactly 7 days and startDate falls on the calendar's weekStartDay","when":"always","field":"StartDate","severity":"error"},{"rule":"weekCount at Year grain must be 52 or 53. A value of 53 requires the calendar's leapWeekPlacement to be other than NotApplicable","when":"always","field":"WeekCount","severity":"error"},{"rule":"weekCount must roll up exactly: the sum across the Period rows of a Quarter equals that Quarter's weekCount, and the sum across the Quarter rows of a Year equals that Year's weekCount. This is the check that catches a leap week inserted in one place and counted in another","when":"always","field":"WeekCount","severity":"error"},{"rule":"parentPeriod must reference a row of the same retailCalendar and the same fiscalYear at the next coarser grain, and its date range must contain this row's. Null only at grain = 'Year'","when":"always","field":"ParentPeriod","severity":"error"},{"rule":"priorYearPeriod must reference a row of the same retailCalendar and the same grain in fiscalYear - 1. Null only for the calendar's firstFiscalYear. Matching on periodNumber alone is not sufficient and must not be used as a fallback — across a 53-week year it is off by one week for the whole year","when":"always","field":"PriorYearPeriod","severity":"error"},{"rule":"No Week-grain rows are generated for a calendar whose calendarType is 'GregorianMonth'","when":"create","field":"Grain","severity":"error"},{"rule":"Regeneration extends the horizon FORWARD only. A row whose date range has passed, or that is referenced by a Closed or Posted Financial Summary, must never be re-dated or deleted — the counterpart entry in an external general ledger already names this period","when":"update","field":"StartDate","severity":"error"},{"rule":"periodNumber must fall in the range the grain permits: 1-53 for Week, 1-12 for Period, 1-4 for Quarter, exactly 1 for Year","when":"always","field":"PeriodNumber","severity":"error"}],"lifecycle":null,"calculations":[{"info":"The +1 is because endDate is inclusive. 7 for a Week, 371 for a 53-week Year.","name":"dayCount","formula":"endDate - startDate + 1","trigger":"query-time"},{"info":"Deliberately NOT stored. A stored flag needs a daily job in every timezone the estate spans, and a stale 'current period' flag is worse than none — it is believed.","name":"isCurrent","formula":"startDate <= today(Location.timezone) <= endDate","trigger":"query-time"},{"info":"Not stored. A generated English label on every row is a translation problem the presentation layer already solves.","name":"displayLabel","formula":"formatted from grain, fiscalYear and periodNumber, with 4-5-4 period names resolved from the calendar's yearEndAnchorMonth","trigger":"query-time"},{"info":"The date range a year-over-year query should actually filter on. Following the reference is correct across a 53-week year; subtracting 364 or 365 days is not.","name":"comparableDateRange","formula":"priorYearPeriod.startDate through priorYearPeriod.endDate","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Generated from the parent calendar's definition. The definition is immutable once Active precisely so that these rows stay valid — re-dating a generated period is the failure the immutability rule exists to prevent","entity":"Retail Calendar"},{"rule":"Financial Summary.fiscalPeriod is free text today. Where a Retail Calendar exists it should carry this entity's code, so that a close can be traced to a defined period rather than to a label someone typed. Not changed unilaterally — logged as an open question","entity":"Financial Summary"},{"rule":"Sale.fiscalDate resolves to exactly one row per grain. Resolution uses the Location's timezone falling back to Company.timezone, since neither this entity nor Retail Calendar carries one","entity":"Sale"},{"rule":"Inventory movement and revenue must resolve through the same calendar, or a period closes with COGS and sales drawn from different date ranges","entity":"Stock Ledger"},{"rule":"The period a DATE falls in is defined here. The date a transaction belongs to is not — a store trading past midnight attributes 01:00 sales to the prior trading day, and that boundary has no central definition in the registry. Location Traffic models it locally as businessDate","entity":"Location"}]}},{"name":"Role","class":"Dictionary","subsystem":"ACCESS","area":"Security & Permissions","desc":"A named set of Permissions assigned to Users — 'Store Manager', 'Buyer', 'Read-only Auditor'. Role is the TENANT-SCOPED member of the ACCESS dictionary family: Permission, Area and Scope are the platform catalogue of what the software can do, identical everywhere, while Role is where a Company composes that catalogue into bundles matching how it actually staffs its business. It is scoped on TWO dimensions, company and application, and code and name are unique on that composite — so one Company may hold a 'Store Manager' for the portal and a separate 'Store Manager' for a POS application, each granting a different set of permissions. Because a permission code names its application in its first segment, every Permission a Role grants must belong to that Role's application.","status":"draft","properties":[{"n":"application","r":true,"t":"entityDetail","re":"Application","info":"The Application this Role grants access within. Required, and one half of the uniqueness composite with company. A Role never spans applications: the permissions it grants carry the application in the first segment of their code, so a cross-application Role would hold codes it cannot coherently be scoped to. A user who needs access in two applications holds two Roles."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"The Company tenant this Role belongs to. Required — redeclared from LookupEntity, whose company is optional so platform-global dictionaries can omit it, per tenant-scoped-dictionary-declares-company. The other half of the uniqueness composite with application. Roles are composed per tenant: two Companies may both have a 'Store Manager' granting entirely different permissions."},{"n":"description","t":"string","info":"Optional long-form explanation of what this role is for and who should hold it."},{"n":"name","r":true,"t":"string","u":true,"info":"Display name, unique within the COMPANY + APPLICATION composite. Redeclared from LookupEntity to tighten uniqueness and to record its scope: role names are what administrators pick from when assigning access, so two identically named roles within one application in one tenant is an operational hazard rather than a cosmetic one — while the same name across two applications is normal and expected. Contrast Permission.name, which is deliberately NOT unique at all because sibling codes share a display label."},{"n":"permissions","r":true,"t":"array","re":"Permission","info":"The Permissions this Role grants, referenced by Permission.code. Every code listed must carry this Role's application in its first segment. Empty array is the minimum default, though businessValidation requires at least one before the Role may be assigned. Field-level codes listed here narrow rather than grant — they take effect only alongside the corresponding resource-level permission."}],"service":"APR Access","ext":"LookupEntity","shopify":"Staff roles and collaborator permissions","notes":"Rebased onto LookupEntity 01 Sep 2026 (delete-and-recreate, since extends is fixed at creation), together with Permission, Area and Scope. The former roleId string was dropped in favour of the inherited UUID id per no-redundant-entity-id; code and name now come from the base schema, with name redeclared to carry its composite uniqueness.\n\nSCOPING RESOLVED 01 Sep 2026 (question 43). A Role is scoped to COMPANY + APPLICATION, and code and name are unique on that composite. The pre-migration entity was contradictory — its notes claimed 'Role scope targets are: Client, Application, Environment' while its validation rule scoped name uniqueness to the Company, and it modelled none of the four. Client and Environment are NOT Role scoping dimensions; Client is the deployment boundary that already contains the Company, and a Role is expected to survive promotion between environments rather than being seeded per environment.\n\nWHY THE COMPOSITE MATTERS: uniqueness on Company alone would have blocked a tenant from holding a 'Store Manager' in the portal and another in a POS application — two genuinely different bundles that share an obvious name. Uniqueness on Application alone would have leaked one tenant's staffing model into every other tenant. Both halves are load-bearing.\n\nTHE APPLICATION AGREEMENT RULE is the practical consequence and is stated as a cross-entity constraint: because a permission code carries its application in the first segment (portal:product:read), a Role's application must match the first segment of every code it grants. The check is a string prefix comparison at grant time. Without it the failure is silent — a mis-scoped permission never matches a guard in the application the Role is actually used in, so access is denied with nothing in the data looking wrong.\n\nTENANT SCOPING: Role declares company required while its three siblings carry company null. The split follows what each entity IS — Permission, Area and Scope describe the software's capabilities and are seeded identically for every tenant; Role describes one Company's staffing model within one Application.","related":["Permission","User","Company","Application"],"bv":{"rules":[{"rule":"code is required and unique on the COMPANY + APPLICATION composite, not on Company alone. It is the stable identifier for the Role in assignments and integrations.","when":"create","field":"code","severity":"error"},{"rule":"name is required and unique on the COMPANY + APPLICATION composite. Unlike Permission.name, a Role name is the value administrators select when granting access, so a duplicate within one application causes misassignment. The same name in a different application is valid and expected.","when":"create","field":"name","severity":"error"},{"rule":"company is required. Role is the tenant-scoped member of the ACCESS dictionary family; Permission, Area and Scope are platform-global and carry company null.","when":"create","field":"company","severity":"error"},{"rule":"application is required. A Role is always scoped to exactly one Application — it never spans applications, because the permissions it grants name their application in the first segment of their code.","when":"create","field":"application","severity":"error"},{"rule":"A Role is deactivated (isActive false), never deleted, while any User still holds it — deleting it would silently strip access rather than failing loudly.","when":"delete","field":"isActive","severity":"error"}],"lifecycle":null,"calculations":[{"name":"permissionCount","formula":"COUNT(permissions)","trigger":"query-time"},{"name":"userCount","formula":"COUNT(User WHERE roles CONTAINS this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Every Permission code in the permissions array must carry this Role's application.slug as its FIRST SEGMENT. A portal-scoped Role granting 'pos:product:read' is incoherent: the Role claims to govern access in one application while handing out a capability that only exists in another. This is checkable cheaply at grant time by string prefix and should be, because the failure is otherwise silent — the permission simply never matches any guard in the application the Role is used in.","entity":"Permission"},{"rule":"Must have at least one Permission assigned before the Role may be granted to a User. Every referenced code must resolve to an active Permission (isActive true).","entity":"Permission"},{"rule":"A field-level permission in the permissions array narrows an operation rather than granting it. Evaluation is resource permission first, then field mask: portal:product:cost:read has no effect unless the Role also carries portal:product:read.","entity":"Permission"},{"rule":"Effective access for a token is the INTERSECTION of the holder's Role permissions and the token's granted Scopes — never the union. A Scope cannot grant what no Role confers.","entity":"Scope"},{"rule":"application must reference a registered Application. Deactivating an Application should deactivate the Roles scoped to it, since their permissions no longer resolve anywhere.","entity":"Application"}]}},{"name":"Sale","class":"Transactional","subsystem":"CONNECT","area":"Sales & Orders","desc":"The completed revenue transaction. Records final quantities, prices, taxes, and payment. Triggers inventory decrement. Can exist independently (e.g. POS cash-and-carry) or originate from an Order. A single Order may produce multiple Sales when fulfilled in separate shipments or pickups.","status":"draft","properties":[{"n":"associate","t":"entityDetail","re":"Employee","info":"Sales associate who initiated this sale."},{"n":"billCustomer","t":"entityRef","re":"Customer","info":"Billing customer."},{"n":"cashier","t":"entityRef","re":"Employee","info":"The employee who processed this sale. References an Employee whose Functions includes 'Cashier'."},{"n":"channel","r":true,"t":"entityDetail","re":"Sales Channel"},{"n":"charges","r":true,"t":"array","info":"Customer charges (store credit, gift card, house charge)."},{"n":"coupons","r":true,"t":"array","info":"Coupons applied to this sale."},{"n":"customer","t":"entityRef","re":"Customer","info":"The customer making the purchase."},{"n":"destTaxArea","t":"string","info":"Destination tax area for tax calculation."},{"n":"deviceNo","t":"string","info":"POS device number where the sale was processed."},{"n":"discounts","r":true,"t":"array","info":"Sale-level discounts applied."},{"n":"drawerMemoNo","t":"string","info":"Cash drawer memo number for reconciliation."},{"n":"fees","r":true,"t":"array","info":"Sale-level fees (e.g. restocking, shipping)."},{"n":"fillFromLocation","t":"entityDetail","re":"Location","info":"Location fulfilling the order, if different from sale location."},{"n":"fiscalDate","r":true,"t":"string","info":"Fiscal date for accounting period assignment."},{"n":"lines","r":true,"t":"array","info":"Array of SaleLine sub-documents. Each line records a product sold in the transaction."},{"n":"location","r":true,"t":"entityDetail","re":"Location"},{"n":"payments","r":true,"t":"array"},{"n":"salesOrder","t":"entityRef","re":"Sales Order","info":"Optional — null for standalone POS sales; references the originating Sales Order when sale is part of order fulfillment"},{"n":"salesOrderNo","t":"string","info":"Reference to originating sales order number."},{"n":"salesPersons","r":true,"t":"array","re":"Employee","info":"One or more sales associates credited on this transaction. References Employees whose Functions includes 'SalesPerson'."},{"n":"salesReceiptNo","r":true,"t":"string"},{"n":"saleType","r":true,"t":"enumeration","v":["DEPOSIT","MIXED","RETURN","SALE"],"info":"Transaction type classification."},{"n":"sourceTaxArea","t":"string","info":"Source tax area for tax calculation."},{"n":"status","r":true,"t":"schema","info":"SaleStatus inline schema capturing current document status."},{"n":"taxes","r":true,"t":"array"},{"c":true,"n":"totalAmount","r":true,"t":"decimal"}],"ext":"TransactionalDocument","shopify":"Order (POS source)","related":["Sales Order","Location","Sales Channel","Employee","Stock Ledger"],"bv":{"rules":[{"rule":"Must equal sum of line totals + taxes - discounts","when":"always","field":"TotalAmount","severity":"error"},{"rule":"Sum of payment amounts must equal TotalAmount (balanced tender)","when":"post","field":"Payments","severity":"error"},{"rule":"Optional — Sale can exist without a Sales Order (standalone POS transaction)","when":"always","field":"SalesOrder","severity":"info"},{"rule":"All product classes (Style, Single, Service, Digital, Wallet) can be added","when":"line-add","field":"lines","severity":"info"}],"lifecycle":{"states":["Held","Posted"],"transitions":[{"to":"Posted","from":"Held","conditions":["Payment tendered and balanced","Stock Ledger entries written"]}],"initialState":"Held"},"calculations":[{"name":"TotalAmount","formula":"Sum(LineTotals) + Sum(Taxes) - Sum(Discounts)","trigger":"On line or payment change"},{"name":"lineCount","formula":"COUNT(lines)","trigger":"query-time"},{"name":"paymentCount","formula":"COUNT(payments)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"One Sales Order may produce multiple Sales (e.g. partial shipments). Each Sale references the originating Sales Order when applicable.","entity":"Sales Order"},{"rule":"Posting writes Stock Ledger debit entries per item/location","entity":"Stock Ledger"},{"rule":"Posting decrements SOH at the sale Location","entity":"Item Stock"},{"rule":"If customer-linked, updates purchase history","entity":"Customer"}]},"inlineSchemas":[{"name":"SaleStatus","extends":"OperationalSubDocument","properties":[{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"},{"info":"Current document status of the sale.","name":"value","type":"enumeration","values":["HELD","POSTED"],"required":true}]}]},{"name":"Sales Channel","class":"Operational","subsystem":"CONNECT","area":"Sales & Orders","desc":"Distribution channel through which products are sold. Controls inventory publication and product visibility.","status":"reviewed","properties":[{"n":"autoPublish","r":true,"t":"boolean","info":"When true, newly created products are automatically published to this channel. When false, products must be explicitly assigned. Defaults to false."},{"n":"channelType","r":true,"t":"enumeration","v":["Online","PointOfSale","Marketplace","Social"],"info":"Classification of the sales channel. Determines channel-specific behaviour such as checkout flow and fulfilment routing."},{"n":"configuration","r":true,"t":"valueType","info":"Reference to this Sales Channel's configuration record in the CONFIG subsystem. Uses the ConfigurationRef value type (config: entityDetail → Config, templateCode: string). Holds channel-specific behavior — catalog scope, pricing strategy, fulfillment routing — consumed at order-capture time."},{"n":"connector","t":"string","info":"Name of the external connector that powers this channel (e.g. 'shopify', 'teamwork'). Null for native/internal channels."},{"n":"items","r":true,"t":"array","re":"Item","info":"Array of Item IDs published to this sales channel. Derived from Items whose Product is published to this channel and whose salesChannels indicate isPublished = true."},{"n":"locations","r":true,"t":"array","re":"Location","info":"Array of SalesChannelLocation inline schema objects — the Locations attached to this Sales Channel and each one's publishing state ({ location: EntityDetail→Location, publishingStatus: 'Active'|'Inactive', inactiveReason: string }). Required with an empty-array default per array-must-be-required. Only entries with publishingStatus = 'Active' determine which Locations can fulfil orders and hold publishable inventory for this channel; 'Inactive' entries are retained so the attachment and the reason it stopped publishing survive, and they must carry inactiveReason. location is unique within the array.\n\nCHANGED 11 Sep 2026 from a bare array of Location IDs. Membership was previously the only signal, so suspending a Location on a channel meant deleting the row — which destroyed the history of the attachment and any record of why it ended, and made a re-add indistinguishable from a first assignment. The publication state belongs on the link, not on its presence or absence."},{"n":"name","r":true,"t":"string"},{"n":"products","r":true,"t":"array","re":"Product","info":"Array of Product IDs published to this sales channel. A Product is published when its salesChannels array contains an entry for this channel with isPublished = true."},{"n":"salesChannelNo","r":true,"t":"integer"},{"n":"status","r":true,"t":"schema","info":"Lifecycle status of the sales channel. Tracks current state and who/when it was last changed."}],"ext":"OperationalDocument","shopify":"Channel and Publication resources","related":["Item","Location","Product","Sales Order"],"bv":{"rules":[{"rule":"Must be unique within the Company","when":"create","field":"salesChannelNo","severity":"error"},{"rule":"location must be unique within locations — at most one entry per Location","when":"always","field":"locations","severity":"error"},{"rule":"inactiveReason is required when locations[].publishingStatus = 'Inactive'","when":"always","field":"locations","severity":"error"},{"rule":"inactiveReason must be null when locations[].publishingStatus = 'Active' — clearing it is part of reactivating a Location, so a stale reason cannot survive on a publishing link","when":"always","field":"locations","severity":"error"},{"rule":"publishingStatus defaults to 'Active' when a Location is first assigned to the channel","when":"create","field":"locations","severity":"info"},{"rule":"Only locations[] entries with publishingStatus = 'Active' participate in fulfilment routing and inventory publication for this channel. 'Inactive' entries are retained for history and reporting and are never removed on suspension","when":"read","field":"locations","severity":"info"}],"lifecycle":{"states":["Draft","Active","Archived"],"disallowed":[{"to":"Draft","from":"Active","reason":"Sales Channels cannot revert to Draft once activated"},{"to":"Draft","from":"Archived","reason":"Sales Channels cannot revert to Draft once activated"}],"transitions":[{"to":"Active","from":"Draft","conditions":["At least one Location is assigned with publishingStatus = 'Active'","Name is set"]},{"to":"Archived","from":"Active","conditions":["No open Sales Orders referencing this channel","No Products are published to this channel"]},{"to":"Active","from":"Archived","conditions":[]}],"initialState":"Draft","terminalExits":["Draft"]},"calculations":[{"name":"productCount","formula":"COUNT(Product WHERE salesChannels CONTAINS this)","trigger":"query-time"},{"name":"itemCount","formula":"COUNT(Item via Product WHERE salesChannels CONTAINS this)","trigger":"query-time"},{"info":"Number of Locations currently publishing to this channel. The figure that matters for fulfilment coverage — total COUNT(locations) includes suspended links and overstates it.","name":"activeLocationCount","formula":"COUNT(locations WHERE publishingStatus = 'Active')","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Cannot delete or archive a Sales Channel while any Products are published to it","when":"delete, archive","entity":"Product"},{"rule":"Cannot delete or archive a Sales Channel while any Items are published to it","when":"delete, archive","entity":"Item"},{"rule":"locations[].location must reference a valid Location within the Company","when":"always","entity":"Location"},{"rule":"A Location may only be set to publishingStatus = 'Active' on this channel while the Location itself is Active","when":"always","entity":"Location"},{"rule":"Setting a locations[] entry to publishingStatus = 'Inactive' must not orphan open Sales Orders already routed to that Location on this channel — existing orders continue to fulfil, only new routing is excluded","when":"update","entity":"Sales Order"}]},"inlineSchemas":[{"name":"SalesChannelStatus","schema":"schemas/sales-channel/SalesChannelStatus.ts","properties":[{"info":"Current lifecycle state of the sales channel.","name":"status","type":"enumeration","values":["Draft","Active","Archived"],"required":true},{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"}]},{"name":"SalesChannelLocation","info":"One Location's participation in this Sales Channel. Replaces the former bare array of Location IDs: membership alone could not say whether a Location is currently publishing to the channel, so an operator who needed to pull a store off a channel had to remove the row entirely and lose the fact that it was ever attached, along with the reason it came off. Carrying publishingStatus on the link keeps the relationship and records its state. location is unique within Sales Channel.locations — at most one entry per Location. inactiveReason is conditionally required: it must be present when publishingStatus = 'Inactive' and is meaningless otherwise, so it is declared optional here and enforced by the business rule on Sales Channel.locations. Deliberately NOT a free-standing entity — the pair has no identity outside the channel that owns it, and no other entity references it.","schema":"schemas/sales-channel/SalesChannelLocation.ts","properties":[{"info":"The Location participating in this Sales Channel. Unique within locations — at most one entry per Location.","name":"location","type":"entityDetail","required":true,"relatedEntity":"Location"},{"info":"Whether this Location currently publishes to the channel. Active = the Location can fulfil orders and hold publishable inventory for this channel. Inactive = the link is retained for history and reporting but the Location is excluded from channel fulfilment routing and inventory publication. Required with a default of 'Active' on assignment.","name":"publishingStatus","type":"enumeration","values":["Active","Inactive"],"required":true},{"info":"Why this Location is not publishing to the channel. REQUIRED when publishingStatus = 'Inactive' — see the business rule on Sales Channel.locations — and must be null when publishingStatus = 'Active'. Conditional requirement is expressed as a business rule rather than required=true because the field is only meaningful in one of the two states.","name":"inactiveReason","type":"string"}]}]},{"name":"Sales Curve","class":"Dictionary","subsystem":"CONNECT","area":"Merchandising","desc":"The shape of a tenant's trading year: the share of annual sales that falls on each day, indexed by RETAIL WEEK x DAY OF WEEK rather than by calendar date, with the shares over a full year summing to 1. A curve exists to answer the one question every buy window asks of history — what proportion of a year's demand falls between these two dates — for a window of any length, starting on any day, in any future year.\n\nWHY WEEK x DAY OF WEEK AND NOT A DATE SERIES. A date series is a record of a year that has already happened; applying it forward means re-dating every point, and re-dating is precisely where a holiday slides off its trading week. The retail-week x day-of-week index carries the two effects that matter and keeps them separable: the seasonal profile across the year, and the day-of-week profile within a week. Both are load-bearing. In the FY2025 client export supplied for the demand-planning build, December ran 9.8% of the year against April at 7.3%; within the week Saturday ran 1.49x an average day and Friday 1.38x, while SUNDAY ran 0.82x — a low day, below Wednesday. Any window that does not start on a week boundary picks up a different mix of weekdays, so a week-grain curve mis-sizes it, and the intuitive both-weekend-days-peak assumption mis-sizes it in the opposite direction. Single-day events are where the grain earns its keep: Valentine's Day at 1.10% of annual sales is roughly four times an average day, and a week-grain curve smears it into invisibility.\n\nCLOSURE DAYS ARE NEAR ZERO, NOT MERELY QUIET. Christmas at 0.002% of annual sales, Thanksgiving at 0.010%, Easter Sunday 0.057% — against roughly 0.27% for an average day. A curve that treats a closed day as an ordinary slow day credits a window with selling exposure the estate never had, and the error compounds across a window that spans several of them. isNonTrading marks the day so the effect survives derivation, import and rescaling rather than being reconstructed by whoever consumes the curve next.\n\nTHE CURVE IS INDEXED BY A RETAIL CALENDAR AND IS MEANINGLESS WITHOUT ONE. Retail week 47 is a span of dates only once a Retail Calendar has said where the fiscal year starts and which weekday a week begins on. A curve therefore names its calendar, and a plan that resolves a window through a different calendar than the curve was built on is comparing two different week 47s — which produces a plausible number rather than an error.\n\nSHARES, NOT VOLUMES. A curve carries proportions only; it never carries revenue or unit values, so it can be shared across locations of different sizes, retained without disclosing trading figures, and rescaled to any location's volume by the consumer. The basis it was DERIVED from — net sales or units — is declared, because the two diverge wherever average unit retail moves seasonally.\n\nASSIGNMENT IS HELD BY THE CONSUMER, NOT LISTED HERE. A curve does not enumerate the products that use it; products and classifications reference the curve. The intended resolution order is product override, then classification override, then the pattern derived from the item's own demand, with the resolved level reported on every planning line. See the open question — the reference properties that carry that precedence are not yet declared on Product, Item or Classification.","status":"draft","properties":[{"n":"basis","r":true,"t":"enumeration","v":["NetSales","Units"],"info":"What the shares were derived from. NetSales is the natural basis — reporting produces sales value, and the client export this model was built against is a percentage of annual net sales. Units is the basis a buy recommendation actually needs, since a replenishment quantity is a count of units. The two diverge wherever average unit retail moves seasonally: a markdown period holds a larger share of units than of value, so a value-basis curve under-weights a clearance week in unit terms. Declared rather than assumed so a consumer can decide whether to convert through AUR or accept the approximation, and so a plan can state which it used. Never inferred from the numbers — a share vector looks identical either way."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Required, overriding LookupEntity where company is optional so platform-global dictionaries can omit it — the override contract described by tenant-scoped-dictionary-declares-company. (The original note here said LookupEntity provides no company property, following the 01 Sep 2026 audit; that was withdrawn on 03 Sep 2026 — LookupEntity has always declared it optional, and only the schema's `provides` summary omitted it.) A trading shape is a fact about one tenant's estate and its holidays, never a platform-global constant."},{"n":"leapWeekRule","r":true,"t":"enumeration","v":["DeclaredInPoints","RepeatFinalWeek","ProrateAcrossYear"],"info":"How the curve behaves when it is applied to a 53-week retail year but was derived from a 52-week one (or the reverse). DeclaredInPoints means the curve carries its own week-53 points and nothing is inferred — correct for a curve derived from a 53-week year. RepeatFinalWeek reuses week 52's day shares for week 53 and renormalizes, which keeps the year-end trading shape intact and is usually right for calendars whose leap week sits at year end. ProrateAcrossYear spreads the extra week's share across every week, which is wrong for seasonal assortments and right only for genuinely flat ones. Required, because the fallback that gets chosen silently is always RepeatFinalWeek, and on a Christmas-weighted assortment that quietly adds a peak week."},{"n":"points","r":true,"t":"array","info":"SalesCurvePoint entries — one per retail week x day of week, 364 for a 52-week curve and 371 for a 53-week one. The complete vector IS the curve: it is always read as a whole to integrate a date range, and no document ever references an individual point. That is the deliberate difference from Retail Calendar Period, which is a separate entity precisely BECAUSE documents reference single periods (a Financial Summary posts against one, a year-over-year join resolves through one). Modelled inline here for the same reason it was modelled separately there — the access pattern, not the row count, decides. Empty array default; a curve with no points is Draft by definition and can never be Active."},{"n":"retailCalendar","r":true,"t":"entityDetail","re":"Retail Calendar","info":"The calendar that gives retailWeek its meaning. Without it, week 47 is an integer rather than a span of dates. A curve and the plan that consumes it must resolve through the SAME calendar: a 4-4-5 January-anchored week 47 and an NRF 4-5-4 February-anchored week 47 are different weeks of the year, and integrating a window across the mismatch yields a plausible figure and no error anywhere."},{"c":true,"n":"shareTotal","t":"decimal","info":"Sum of every point's share. Must equal 1 within tolerance before the curve can be activated. Materialized rather than computed on read because it is the curve's own check on itself and the only cheap detector of a truncated or double-pasted import — a curve whose shares total 0.83 does not fail, it under-buys by 17% on every window, uniformly, forever."},{"n":"sourceFiscalYear","t":"integer","info":"The fiscal year the curve was derived from or imported for, labelled per the calendar's fiscalYearLabelRule. Null for an authored curve. Kept because a curve ages: a shape derived from a year that contained a store-closure programme, a supply failure or a promotional calendar the business has since abandoned is not a description of normal trade, and knowing which year it came from is the only way to notice."},{"n":"sourceReference","t":"string","info":"Where the curve came from, in the tenant's own terms — the report, export, query or file it was imported from. Free text and deliberately not a Media reference: the useful artefact is usually a Looker look or a scheduled export that will be re-run, not a file to retain."},{"n":"sourceType","r":true,"t":"enumeration","v":["Imported","DerivedFromHistory","Authored"],"info":"How the shares were produced. Imported: header-matched from the tenant's own reporting export, which is the path that makes the seasonality theirs rather than a generic shape. DerivedFromHistory: computed from sales history at day x item x location grain within the platform. Authored: entered or adjusted by hand, which is legitimate for a new category with no history and is worth being able to distinguish from a measured shape when a recommendation is questioned."},{"n":"status","r":true,"t":"schema","info":"SalesCurveStatus inline schema. Draft while points are being imported or derived and the totals may not yet reconcile; Active once validated and assignable, at which point the points and basis are frozen; Archived when superseded. Distinct from the inherited isActive flag, which governs whether the curve appears in selection lists — an Archived curve is still named by every Replenishment Plan ever run against it and must never be deleted."}],"service":"Demand Planning","ext":"LookupEntity","notes":"PROPOSED — not yet reviewed. Created 03 Sep 2026 from the Merchandising: Demand Planning, Forecasting and Replenishment PRD (Sean Finnigan, 03 Sep 2026), requirement R2 / R2a / R2b. The PRD's evidence section records that seasonality \"is not available to any operational decision\" today and that the retail calendar existed only in the analytics layer; Retail Calendar and Retail Calendar Period closed the second gap earlier the same day, and this entity closes the first.\n\nWHY Dictionary / LookupEntity. A curve is selected by reference, has exactly one tenant default, is retired without deletion and carries a code and a name — which is the LookupEntity shape unchanged. It is not an operational document: nothing happens to a curve, it is authored once and then consumed. The cost of the choice is the same one logged against Retail Calendar: LookupEntity provides idmpKey but not identifiers, so there is nowhere to record the reporting layer's own key for the same curve. That matters for re-import and is logged rather than papered over.\n\nWHY THE POINTS ARE INLINE AND THE CALENDAR'S PERIODS ARE NOT. Retail Calendar Period is a separate entity because documents reference individual periods — a Financial Summary posts against one, every year-over-year join resolves through one, and a week must point at its comparable week last year. Nothing ever references a single curve point: the curve is read as a complete vector to integrate a date range, and a point in isolation is meaningless. The access pattern decides, not the row count. 371 small values also sit comfortably inside a dictionary read, and the alternative would put roughly 371 rows per curve per tenant into a table whose only query is \"give me all of them\".\n\nWHY Inventory and Allocation. The forcing consumer is Replenishment Plan, which lives there. Analytics would also be defensible — the curve is derived in the reporting layer and is used for forecasting as well as buying — and a Merchandising area does not exist in the registry today. Worth revisiting if the demand-planning module grows enough entities to justify its own area; the PRD proposes Merchandising as a top-level Fulcrum section (R16), which is a UI decision, not a registry one.\n\nDELIBERATE OMISSIONS:\n- No revenue or unit VALUES. Shares only. This is what lets a curve be shared across an estate, retained without disclosing trading figures, and rescaled by the consumer. The client fixture retained during the prototype build carries shares only for the same reason.\n- No weekCount property. Derived as MAX(retailWeek) over the points, which are the authority on how many weeks the curve covers. A declared copy that disagrees with the array is a second source of truth for one fact.\n- No promotional overlay. A curve describes the shape of trade as it happened, promotions included. De-promoting history and re-applying planned lift belongs to the forecaster (PRD R17, Phase 2) and to Promotions, not here. Keeping them separate is what allows a de-promoted curve and a raw curve to coexist as two curves rather than as two interpretations of one.\n- No item or location dimension. A curve is a shape; the assortment and the estate apply it. An item whose shape genuinely differs gets its own curve through the assignment precedence.\n- No confidence or fit statistic. Curve quality is a question the forecaster's holdout scoring answers (R17a); putting a quality number on reference data invites it to be trusted without the measurement behind it.\n\nOPEN, and worth deciding before build:\n(1) Basis conversion. Curves derive naturally from net sales but are applied to unit quantities, and the two diverge where average unit retail moves seasonally. Convert through AUR, or accept the approximation? PRD Q12; logged as a registry question.\n(2) The assignment properties do not exist. Product, Item, Classification and Item Stock Group carry no reference to a Sales Curve, so R2b's precedence is documented here and implementable nowhere. The shape should follow the weeks-of-supply precedent settled earlier today — a default on the parent, an override on the child, declared in the inheritance blocks so the conventions can fire.\n(3) Import header matching (ratio or percentage, tab or comma separated, column synonyms) is import-layer behaviour and is deliberately not modelled as schema. The one place it touches the model is the ratio-versus-percentage ambiguity, which the share property pins to a ratio.","related":["Retail Calendar","Retail Calendar Period","Company","Product","Item","Classification","Item Stock Group","Replenishment Plan","Stock Ledger","Location"],"bv":{"rules":[{"rule":"code must be unique within the Company","when":"create","field":"Code","severity":"error"},{"rule":"At most one Sales Curve per Company may carry isDefault = true. The default is the chain curve that applies to any item resolving neither a product nor a classification override","when":"always","field":"IsDefault","severity":"error"},{"rule":"shareTotal must equal 1 within a tolerance of 0.0005 before status may move to Active. A curve that totals 0.83 does not fail at run time — it under-buys every window by 17% uniformly, which is invisible in a spot check and obvious only in aggregate months later","when":"update","field":"ShareTotal","severity":"error"},{"rule":"points must contain exactly one entry per (retailWeek, dayOfWeek) pair for every week from 1 to the curve's week count. Duplicates double-count a day; gaps integrate to less than the true share over any window that crosses them, and both fail silently","when":"always","field":"Points","severity":"error"},{"rule":"Every share must be greater than or equal to 0. A negative share is arithmetically expressible and physically meaningless — it subtracts demand from a window","when":"always","field":"Points","severity":"error"},{"rule":"Week 53 points may be present only when leapWeekRule = 'DeclaredInPoints'. Under RepeatFinalWeek or ProrateAcrossYear the 53rd week is resolved at application time, and a stored week 53 plus a rule that also synthesizes one produces a curve that totals more than 1","when":"always","field":"LeapWeekRule","severity":"error"},{"rule":"IMMUTABLE ONCE ACTIVE. points, basis, retailCalendar and leapWeekRule may not change while status.status = 'Active'. Every Replenishment Plan committed against the curve derived its quantities from these shares; changing them retrospectively rewrites the explanation of a decision already made and orders already raised. A revised shape is a new Sales Curve","when":"update","field":"Points","severity":"error"},{"rule":"A curve referenced by any committed Replenishment Plan may be Archived but never soft-deleted. Deleting it strips the derivation from every plan that used it, which is the audit trail the module exists to provide","when":"delete","field":"IsDeleted","severity":"error"},{"rule":"A day marked isNonTrading should carry a share at or below the curve's average daily share. A closure day with an above-average share means the flag and the data disagree, and the data is usually right — the flag is more often applied to the wrong retail week than the sales are wrong","when":"always","field":"Points","severity":"warning"},{"rule":"sourceFiscalYear should fall within the retail calendar's generated range. A curve derived from a year the calendar cannot resolve cannot be re-derived or verified","when":"always","field":"SourceFiscalYear","severity":"warning"}],"lifecycle":{"states":["Draft","Active","Archived"],"transitions":[{"to":"Active","from":"Draft","conditions":["points complete for every retail week x day of week","shareTotal reconciles to 1 within tolerance","basis and retailCalendar declared","changedBy and changedDate recorded"]},{"to":"Archived","from":"Draft","conditions":["Draft abandoned before assignment"]},{"to":"Archived","from":"Active","conditions":["Superseded by another curve","No new assignment permitted; existing plan references retained"]}],"initialState":"Draft"},"calculations":[{"info":"Must reconcile to 1. The import validation figure.","name":"shareTotal","formula":"SUM(points[].share)","trigger":"On import, derivation, or any change to points while Draft"},{"info":"364 for a 52-week curve, 371 for a 53-week one. Derived rather than stored; a stored count that disagrees with the array is worse than no count.","name":"pointCount","formula":"COUNT(points) = weekCount x 7","trigger":"query-time"},{"info":"52 or 53. Deliberately derived rather than declared — the points are the authority on how many weeks the curve covers, and a declared value that disagrees with them is a second source of truth for one fact.","name":"weekCount","formula":"MAX(points[].retailWeek)","trigger":"query-time"},{"info":"THE reason the entity exists. Integrating a date range against the curve is what turns a measured sales rate into a requirement for a window that is longer, shorter or seasonally different from the window it was measured over. A window not starting on a week boundary picks up a different weekday mix, which is exactly the effect a week-grain curve cannot express.","name":"windowShare","formula":"SUM(points[].share) over every retail week x day of week that the calendar resolves within [windowStart, windowEnd], with week 53 supplied per leapWeekRule","trigger":"query-time"},{"info":"De-seasonalizes a measured period into an annual rate. The denominator is why a flat average of recent sales mis-sizes every window: eight weeks of October is not 8/52 of a year's demand.","name":"impliedAnnualDemand","formula":"analysedPeriodSalesQty / windowShare(analysisWindowStart, analysisWindowEnd)","trigger":"query-time"},{"info":"The multiple of an average day. Materialized on the point because it is the figure a person reads when judging whether a curve is plausible.","name":"dayIndex","formula":"points[].share / (1 / pointCount)","trigger":"On activation"}],"crossEntityConstraints":[{"rule":"A curve's retailCalendar must be the same calendar the consuming Replenishment Plan resolves its analysis and protection windows through. Mismatched calendars integrate different weeks of the year against each other and produce a plausible quantity with no error raised","entity":"Replenishment Plan"},{"rule":"A derived curve is computed from Stock Ledger sale movements at day x item x location grain, over a stated span. Derivation must exclude days on which the item was unavailable, or the curve records the shape of the stockout rather than the shape of demand","entity":"Stock Ledger"},{"rule":"Assignment precedence is product override, then classification override, then a pattern derived from the item's own demand, and the resolved level is reported on every planning line. The reference properties that would carry this — a default on Classification or Item Stock Group, an override on Product, and possibly on Item — are NOT yet declared. Modelled here as a stated contract and logged as an open question rather than added unilaterally to three entities","entity":"Product"},{"rule":"Where a curve is derived at classification grain, the classification's own aggregate demand is the source, not an average of its items' curves — averaging normalized shapes weights a slow item equally with a fast one","entity":"Classification"},{"rule":"Shares are location-independent by construction; a location's volume is applied by the consumer as a scalar. A location whose SHAPE genuinely differs — a tourist store, a campus site — needs its own curve, not a rescaling of the chain's","entity":"Location"}]},"inlineSchemas":[{"name":"SalesCurvePoint","info":"One day of the trading year, identified by its position in the retail week structure rather than by date. The pair (retailWeek, dayOfWeek) is unique within a curve and every combination in 1..weekCount x the seven weekdays must be present — a sparse curve integrates to less than 1 over a window that crosses its gaps, and under-buys silently rather than failing.","properties":[{"info":"The day's share relative to an average day of the same curve: share / (1 / pointCount). Saturday at 1.49 and Sunday at 0.82 in the FY2025 client data. Derived, and materialized only because it is the figure a planner reads to sanity-check a curve — 0.0027 tells nobody anything, 1.49x does.","name":"dayIndex","type":"decimal"},{"info":"The weekday this point covers. Stored as the weekday itself rather than an ordinal position within the week, so a curve stays readable when a tenant's calendar starts its week on a day other than Sunday and so the day-of-week profile survives being read out of context.","name":"dayOfWeek","type":"enumeration","values":["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],"required":true},{"info":"The estate does not trade on this day of the retail year — a public holiday or a scheduled closure. Marked rather than inferred from a low share, because the two are genuinely different: a near-zero share on a trading day is a demand fact that a buy window should account for, while a closure is an absence of selling exposure that a window must not be credited with. Derived curves inherit the flag so damping is not silently lost on re-derivation, and a curve rescaled or borrowed for another location keeps it.","name":"isNonTrading","type":"boolean","required":true},{"info":"Retail week number within the fiscal year, 1 through 52 or 53, resolved against the curve's Retail Calendar. Week 53 is present only on a curve whose leapWeekRule is DeclaredInPoints.","name":"retailWeek","type":"integer","required":true},{"info":"This day's share of annual sales on the curve's declared basis, as a ratio in [0,1] — 0.0110 for a Valentine's Day at 1.10% of the year. Stored as a ratio, never as a percentage, because an import that mixes the two is off by a factor of 100 and is otherwise indistinguishable from a curve for a very quiet estate. Precision matters more than it looks: at four decimal places an average day is 0.0027, so rounding to three loses roughly 10% of the resolution the day-of-week profile depends on.","name":"share","type":"decimal","required":true}]},{"name":"SalesCurveStatus","info":"Authoring lifecycle of the curve, with the audit stamp of who moved it. Modelled as an inline schema on the Retail Calendar precedent rather than as a flat enumeration: activating a curve freezes the shares that every subsequent buy quantity is derived from, which is an accountable act.","properties":[{"info":"The User who last changed the status. Activating a curve is a deliberate act, not a background job — a derived curve is computed automatically but is not adopted automatically.","name":"changedBy","type":"string"},{"info":"When the status last changed. UTC.","name":"changedDate","type":"datetime"},{"info":"Draft: points may be replaced, shareTotal need not reconcile, the curve is not assignable. Active: points, basis and retailCalendar are frozen and the curve may be assigned and consumed. Archived: superseded; no new assignment, every existing reference retained. A revised shape is a new curve, not an edit — editing an Active curve retrospectively changes the derivation of plans already committed against it.","name":"status","type":"enumeration","values":["Draft","Active","Archived"],"required":true}]}]},{"name":"Sales Order","class":"Order","subsystem":"CONNECT","area":"Sales & Orders","desc":"Sales intent document capturing items, pricing, promotions, and fulfillment instructions before completion. A single Sales Order may result in multiple Sales when fulfilled in separate shipments or pickups.","status":"draft","properties":[{"n":"associate","t":"string","info":"Sales associate assigned to this order."},{"n":"billCustomer","t":"entityRef","re":"Customer","info":"Billing customer, if different from the ordering customer."},{"n":"canOnlyShipOnce","r":true,"t":"boolean","info":"If true, all items must ship in a single fulfillment."},{"n":"canShipPartial","r":true,"t":"boolean","info":"Whether partial shipments are allowed."},{"n":"createLocation","t":"string","info":"Location where the order was created."},{"n":"customer","t":"entityRef","re":"Customer"},{"n":"customerServiceNotes","r":true,"t":"array","info":"Array of customer service notes."},{"n":"defaultShipMethod","t":"entityDetail","re":"Shipping Method","info":"Default shipping method for the order."},{"n":"deliveryInfo","t":"schema","info":"Delivery scheduling information including date, time window, and destination."},{"n":"discounts","r":true,"t":"array","info":"Order-level discounts applied."},{"n":"estimatedArrivalDate","t":"datetime","info":"Estimated delivery date to the customer."},{"n":"estimatedShipDate","t":"datetime","info":"Estimated ship date from the fulfillment location."},{"n":"fees","r":true,"t":"array","info":"Order-level fees (shipping, handling, etc.)."},{"n":"fillLocation","r":true,"t":"string","info":"Primary fulfillment location for this order."},{"n":"fulfillmentMethod","r":true,"t":"enumeration","v":["ShipToCustomer","StorePickup","ShipToStore","SameDay","Mixed"],"info":"How the order will be fulfilled."},{"n":"isArchived","r":true,"t":"boolean","info":"Soft archive flag."},{"n":"isFillLocationLocked","r":true,"t":"boolean","info":"If true, the fill location cannot be changed."},{"n":"isGuestCheckout","r":true,"t":"boolean","info":"Whether this is a guest checkout without customer account."},{"n":"isTaxExempt","r":true,"t":"boolean","info":"Whether the order is tax exempt."},{"n":"lines","r":true,"t":"array","info":"Array of SalesOrderLine sub-documents. Each line represents a product ordered by the customer."},{"n":"orderDate","r":true,"t":"datetime","info":"Date the order was placed."},{"n":"payments","r":true,"t":"array","info":"Payment methods and amounts applied to this order."},{"n":"promotions","r":true,"t":"array","re":"Promotion"},{"n":"salesChannel","t":"string","info":"Sales channel code where the order originated."},{"n":"salesOrderNo","r":true,"t":"string"},{"n":"salesReceiptId","t":"string","info":"Reference to the associated Sales Receipt (Sale)."},{"n":"shipCustomer","t":"entityRef","re":"Customer","info":"Ship-to customer, if different from the ordering customer."},{"n":"shipments","r":true,"t":"array","info":"Array of shipment references with tracking information."},{"n":"status","r":true,"t":"schema","info":"SalesOrderStatus inline schema capturing current document status."},{"n":"type","r":true,"t":"enumeration","v":["Customer Order","Online Order","Pre-Order","Special Order","Store Order"],"info":"Classification of the sales order by origin or purpose."}],"ext":"OperationalDocument","shopify":"Order / DraftOrder resource","notes":"SalesOrderLine was DEFINED for the first time on 31 Aug 2026. The lines property had always described \"an array of SalesOrderLine sub-documents\", but no such inline schema existed anywhere in the registry — only SalesOrderStatus did.\n\nThe property set was taken from the implemented Zod schema (src/validation/schemas/salesOrder/SalesOrderItem.ts), which was the only real description of a sales order line in existence. The NAMES AND TYPES are normalized to registry convention, on the standing decision that the registry is the intended state and the implementation catches up to it. The normalizations, so the delta is reviewable rather than hidden:\n- Bare id strings became references: itemId → item (entityRef → Item), fillLocation → entityDetail → Location, fillSalesReceiptIds → fillSales (entityRef → Sale).\n- lineNo became an INTEGER to match PurchaseOrderLine. The implementation makes SalesOrderItem.lineNo a string while PurchaseOrderItem.lineNo is an integer — and those two are the endpoints of a reference to each other, so it currently compares mismatched types. The intended state makes both integers.\n- The implementation's 'associated*' prefix family (associatedPurchaseOrderId, associatedTransferOrder, associatedSalesReceiptLineNo) was dropped for plain reference names, since the registry expresses cross-document references by type rather than by prefix.\n- Three implemented properties collapsed into one pair: associatedTransferOrder, associatedTransforOrderLineNo (whose name contains a typo) and transferOrderId became transferOrder + transferOrderLineId.\n- salesReceiptId + associatedSalesReceiptLineNo became sale + saleLineId.\n- The sub-document is named SalesOrderLine, not the implementation's SalesOrderItem, and the parent array stays 'lines', not the implementation's 'items' — every other document in the registry (Purchase Order, Bill, Goods Receipt, Shipment, Stock Transfer, Transfer Order, Vendor Invoice) calls its children lines.\n\nDROP SHIP FULFILMENT LINK: SalesOrderLine.purchaseOrder + purchaseOrderLineId point at the PurchaseOrderLine raised to fulfil the line, and PurchaseOrderLine.salesOrder + salesOrderLineId point back. Line-level rather than header-level because a sales order line can be part-filled from stock and part bought in. Both sides use the sub-document ID rather than lineNo, following the sourceLineId precedent on Stock Ledger — line numbers can be renumbered, ids cannot. The implementation uses lineNo, so this is a deliberate divergence.\n\nThe reference is stored on BOTH sides so each document answers its own question without scanning the other. That denormalization has a cost worth stating: nothing but the mutual-consistency rule keeps them agreeing, and a one-sided link is a defect rather than a partial state. Note the implementation is currently one-sided in exactly this way — SalesOrderItem carries a proper line-level reference to the PO, while PurchaseOrderItem carries only salesOrderIds, an array of bare header ids with no line number.\n\nThe link is required for PO type Drop Ship and optional otherwise, covering the special-order case — an item bought in for a customer to collect in store is the same relationship without the direct shipment. Both Sales Order.type and SalesOrderLine.type already carry a 'Special Order' value, so that case exists in the model with nowhere until now to record what was purchased for it.\n\nisDropShip on the line and the PO's Drop Ship type must agree for a linked pair.\n\nNOT RESOLVED: a sales order line split across two vendors. purchaseOrderLineId is a single value, so at most one PO line can be recorded. An array would express it but complicates the mutual-consistency rule.\n\nSTILL MISSING AT HEADER LEVEL: the implementation carries netTotal, orderTotal, discountTotal, feeTotal, feeTax, taxTotal, paymentTotal, quantityDeliveryPending, pickupCustomers, saleLocation, haveReadyByDateTime and associatedSalesReceiptId, none of which this entity records. Those are gaps rather than conflicts and were left for a separate pass — see the registry question on Purchase Order / Sales Order drift.","related":["Customer","Sale","Fulfillment Order","Ship Order","Sales Channel","Purchase Order","Item"],"bv":{"rules":[{"rule":"Must have at least one order line","when":"create","field":"Lines","severity":"error"},{"rule":"Must reference a valid active Customer","when":"create","field":"Customer","severity":"error"},{"rule":"All product classes (Style, Single, Service, Digital, Wallet) can be added","when":"line-add","field":"lines","severity":"info"},{"rule":"purchaseOrder and purchaseOrderLineId are set together or neither is set. A reference to a Purchase Order without the specific line is not resolvable, since a PO may carry several lines for the same Item at different destinations","when":"always","field":"lines","severity":"error"},{"rule":"THE LINK MUST BE MUTUAL — if a SalesOrderLine names a PurchaseOrderLine, that PurchaseOrderLine must name this SalesOrderLine back. The reference is stored on both sides so each can answer its own question without scanning the other, which means nothing but this rule keeps them agreeing; a one-sided link is a defect, not a partial state","when":"always","field":"lines","severity":"error"},{"rule":"A line filled from existing inventory carries no purchaseOrder. The link is for goods bought in specifically for this order — a drop ship, or a special order collected in store","when":"always","field":"lines","severity":"info"},{"rule":"Cancelling a line that names a PurchaseOrderLine must surface the linked PO line for review rather than cancelling it silently, since the PO is a commitment to a vendor and may already be shipped","when":"cancel","field":"lines","severity":"error"}],"lifecycle":{"states":["Accepted","Processing","Reviewing","OnHold","DeliveryPending","PickUpReady","Completed","Cancelled","Mixed"],"transitions":[{"to":"Processing","from":"Accepted","conditions":["Payment authorized or COD"]},{"to":"DeliveryPending","from":"Processing","conditions":["All items shipped"]},{"to":"PickUpReady","from":"Processing","conditions":["All items picked and staged"]},{"to":"Mixed","from":"Processing","conditions":["Lines are in multiple states"]},{"to":"Reviewing","from":"Processing","conditions":["Fraud or review flag triggered"]},{"to":"Processing","from":"Reviewing","conditions":["Review cleared"]},{"to":"Cancelled","from":"Reviewing","conditions":["Review rejected"]},{"to":"OnHold","from":"Processing","conditions":["Manual or system hold"]},{"to":"Processing","from":"OnHold","conditions":["Hold released"]},{"to":"Completed","from":"DeliveryPending","conditions":["Delivery confirmed"]},{"to":"Completed","from":"PickUpReady","conditions":["Customer picked up"]},{"to":"Cancelled","from":"Accepted","conditions":["Customer request before processing"]}],"initialState":"Accepted"},"calculations":[{"name":"QuantityOrdered","formula":"Sum of line quantities","trigger":"On line change"},{"name":"QuantityFilled","formula":"Sum of line filled quantities","trigger":"On fulfillment update"},{"name":"QuantityDue","formula":"QuantityOrdered - QuantityFilled - QuantityCancelled","trigger":"On fulfillment or cancellation"},{"name":"QuantityCancelled","formula":"Sum of line cancelled quantities","trigger":"On cancellation"},{"name":"lineCount","formula":"COUNT(lines)","trigger":"query-time"},{"info":"Lines being fulfilled by a purchase raised specifically for this order rather than from stock. Distinguishes an order that can ship today from one waiting on a vendor.","name":"boughtInLineCount","formula":"COUNT(lines WHERE purchaseOrder IS NOT NULL)","trigger":"query-time"},{"name":"fulfillmentOrderCount","formula":"COUNT(Fulfillment Order WHERE salesOrder = this)","trigger":"query-time"},{"name":"saleCount","formula":"COUNT(Sale WHERE salesOrder = this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"One Order may produce multiple Sales (e.g. partial shipments). Each Sale references the originating Order.","entity":"Sale"},{"rule":"A line may be fulfilled by a Purchase Order line raised specifically for it — required for a Drop Ship PO, optional for a special order bought in for collection. The reference is line-to-line in both directions and must agree on both sides","entity":"Purchase Order"},{"rule":"Generates Fulfillment Orders for shipment/pickup","entity":"Fulfillment Order"},{"rule":"Shipment creation updates Order line status","entity":"Shipment"}]},"inlineSchemas":[{"name":"SalesOrderLine","info":"One Item and quantity ordered by the customer. Defined 31 Aug 2026. The property set is taken from the implemented SalesOrderItem Zod schema, which was the only real description of a sales order line in existence; the NAMES AND TYPES are normalized to registry convention, since the registry is the intended state and the implementation is expected to catch up. Quantity properties use the qty prefix, matching PurchaseOrderLine and the inventory entities — the implementation's quantityOrdered/quantityFilled family was normalized on 31 Aug 2026 so the two sides of the drop-ship line link no longer disagree on their own vocabulary. Note the reference-plus-snapshot pairs running through this schema: item alongside name/productCode/description/attributes, and shipToCustomer alongside shipToAddress. In each case the reference carries live identity and the snapshot preserves what was actually committed to at order time.","extends":"OperationalSubDocument","properties":[{"info":"Sales associates credited on this line. Plural per array-name-plural; the implementation calls this 'associate'. The parent Sales Order carries a singular associate — the header records who took the order, the line who is credited for it.","name":"associates","type":"array<string>","required":true},{"info":"Denormalized snapshot of the ordered variant's attribute values (e.g. 'Red', 'Large'), captured at order time so the line still reads correctly if the Item is later changed. Deliberately strings rather than references — a snapshot, not a live link.","name":"attributes","type":"array<string>","required":true},{"info":"DeliveryInfo inline schema. Per-line delivery scheduling, overriding the order-level deliveryInfo.","name":"deliveryInfo","type":"schema"},{"info":"Line description, captured at order time.","name":"description","type":"string"},{"info":"Rollup of discounts[]. Calculated.","name":"discountTotal","type":"decimal","calculated":true},{"info":"SalesOrderDiscount sub-documents applied to this line.","name":"discounts","type":"array","required":true},{"info":"Tax on this line's fees. Calculated.","name":"feeTax","type":"decimal","calculated":true},{"info":"Rollup of fees[]. Calculated.","name":"feeTotal","type":"decimal","calculated":true},{"info":"SalesOrderFee sub-documents applied to this line.","name":"fees","type":"array","required":true},{"info":"Location fulfilling this line. Per-line override of the order-level fillLocation, which is what lets one order source lines from several locations. entityDetail per location-ref-uses-entity-detail; the implementation holds a bare string.","name":"fillLocation","type":"entityDetail","required":true,"relatedEntity":"Location"},{"info":"The Sales that filled this line. An array because a line filled across several shipments produces several. Implementation calls this fillSalesReceiptIds and holds bare id strings.","name":"fillSales","type":"array","required":true,"relatedEntity":"Sale"},{"info":"How this line is fulfilled. Per-line, which is what makes the order-level 'Mixed' value meaningful. Correctly excludes 'Mixed' — a single line is never mixed.","name":"fulfillmentMethod","type":"enumeration","values":["ShipToCustomer","StorePickup","ShipToStore","SameDay"],"required":true},{"info":"Whether this line ships direct from the vendor to the customer. Per-line here, whereas the purchasing side expresses it per-document via the PO type. The two must agree for a linked pair. Per boolean-must-be-required.","name":"isDropShip","type":"boolean","required":true},{"info":"The Item ordered. entityRef per registry convention; the implementation holds a bare itemId string. The live reference — name, productCode, description and attributes hold the snapshot taken at order time.","name":"item","type":"entityRef","required":true,"relatedEntity":"Item"},{"info":"Line number within the sales order. INTEGER, matching PurchaseOrderLine.lineNo. The implementation makes this a string while PurchaseOrderItem.lineNo is an integer, so the reference between the two documents currently compares mismatched types — a defect the intended state resolves by making both integers.","name":"lineNo","type":"integer","required":true},{"info":"Total for this line: qty × unitPrice, less discountTotal, plus feeTotal and taxTotal. Calculated.","name":"lineTotal","type":"decimal","calculated":true},{"info":"Item name captured at order time. Snapshot — renaming the Item later must not rewrite what the customer ordered.","name":"name","type":"string","required":true},{"info":"Unit price after line discounts. Calculated.","name":"netPrice","type":"decimal","calculated":true},{"info":"Product code captured at order time. Snapshot.","name":"productCode","type":"string"},{"info":"Date promised to the customer for this line. UTC.","name":"promiseDate","type":"datetime"},{"info":"The Purchase Order raised to fulfil this line, where goods are bought in rather than taken from stock — a drop ship, or a special order collected in store. Null for lines filled from inventory, the normal case. Paired with purchaseOrderLineId; both set or neither.","name":"purchaseOrder","type":"entityRef","relatedEntity":"Purchase Order"},{"info":"The id of the specific PurchaseOrderLine fulfilling this line. Line-level rather than header-level because a sales order line can be part-filled from stock and part bought in. Uses the sub-document id following the sourceLineId precedent on Stock Ledger, NOT lineNo as the implementation does — line numbers can be renumbered, ids cannot. The reverse reference lives on PurchaseOrderLine.salesOrder/salesOrderLineId and the two must agree.","name":"purchaseOrderLineId","type":"string"},{"info":"Quantity ordered. The unqualified quantity on the line, matching PurchaseOrderLine.qty; the qualified variants below cover the rest. Summed into the order's QuantityOrdered. Renamed from the implementation's quantityOrdered on 31 Aug 2026.","name":"qty","type":"decimal","required":true},{"info":"Quantity cancelled from this line. Summed into the order's QuantityCancelled.","name":"qtyCancelled","type":"decimal","required":true},{"info":"Quantity shipped but not yet confirmed delivered. Summed into the order's quantityDeliveryPending.","name":"qtyDeliveryPending","type":"decimal","required":true},{"info":"qty less qtyFilled and qtyCancelled. Calculated.","name":"qtyDue","type":"decimal","calculated":true},{"info":"Quantity fulfilled against this line. Summed into the order's QuantityFilled.","name":"qtyFilled","type":"decimal","required":true},{"info":"The Sale recording this line's revenue. Paired with saleLineId. Distinct from fillSales, which lists every Sale that contributed to filling the line — the relationship between the two was not evident in the implementation and should be settled before build.","name":"sale","type":"entityRef","relatedEntity":"Sale"},{"info":"The id of the line on the referenced Sale. Required whenever sale is set.","name":"saleLineId","type":"string"},{"info":"Per-line shipping method, overriding the order's defaultShipMethod.","name":"shipMethod","type":"entityDetail","relatedEntity":"Shipping Method"},{"info":"Address value type. A STATIC SNAPSHOT of where this line ships, copied at order time rather than referenced from the Customer's address book — a customer may later edit or delete the address they used, and a reference would dangle or silently rewrite what was agreed. Per-line, which is what lets one order deliver lines to different addresses. For a drop-ship line this is what the vendor is told to ship to, and must match the linked Purchase Order's dropShipAddress. Paired with shipToCustomer.","name":"shipToAddress","type":"valueType","valueType":"Address"},{"info":"The Customer this line ships to, where it differs from the order's shipCustomer — a gift line, or a multi-recipient order. Carries live identity for service, returns and history, which the shipToAddress snapshot deliberately does not. Neither replaces the other: a Customer may hold many ship-to addresses, so the reference alone cannot say which was used.","name":"shipToCustomer","type":"entityRef","relatedEntity":"Customer"},{"info":"Per-line lifecycle status. Mirrors the order-level enum minus 'Mixed', which exists at header level precisely to describe lines sitting in several of these states at once.","name":"status","type":"enumeration","values":["Accepted","Cancelled","Completed","DeliveryPending","OnHold","PickUpReady","Processing","Reviewing"],"required":true},{"info":"Total tax on this line.","name":"taxTotal","type":"decimal","required":true},{"info":"The Transfer Order moving stock to satisfy this line. Paired with transferOrderLineId. COLLAPSES THREE IMPLEMENTED PROPERTIES — associatedTransferOrder, associatedTransforOrderLineNo (whose name contains a typo) and transferOrderId, the first and last of which appear to serve the same purpose.","name":"transferOrder","type":"entityRef","relatedEntity":"Transfer Order"},{"info":"The id of the line on the referenced Transfer Order. Required whenever transferOrder is set.","name":"transferOrderLineId","type":"string"},{"info":"Line type. Narrower than the order-level type, which also offers Online Order, Pre-Order and Store Order — those describe how the order arrived, which is a header fact, while these describe how the line is sourced.","name":"type","type":"enumeration","values":["Customer Order","Special Order"]},{"info":"Unit price before discounts.","name":"unitPrice","type":"decimal","required":true}]},{"name":"SalesOrderStatus","extends":"OperationalSubDocument","properties":[{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"},{"info":"Current document status of the sales order.","name":"value","type":"enumeration","values":["Accepted","Cancelled","Completed","DeliveryPending","Mixed","OnHold","PickUpReady","Processing","Reviewing"],"required":true}]}]},{"name":"Scope","class":"Dictionary","subsystem":"ACCESS","area":"Security & Permissions","desc":"An OAuth2 scope string that maps to one or more Areas or Permissions. Presented during authorization to define the access surface of a token: the scope is what a client ASKS FOR and a user CONSENTS TO, and it bounds the permissions a token may carry regardless of what the user's Roles grant. Platform-global — seeded per Application and granted to Clients, identical across tenants — so company is null. NOTE THE NAME COLLISION: this entity is unrelated to the second segment of a permission code, which is also called 'scope' in the code grammar but names the RESOURCE (product, sales-order). The two are historically named alike and mean different things.","status":"draft","properties":[{"n":"areas","r":true,"t":"array","re":"Area","info":"Areas bundled wholesale into this scope — grants every Permission in each listed Area, including permissions added to that Area later. Empty array is the minimum default."},{"n":"description","t":"string","info":"Optional long-form explanation of the access surface. Surfaced on the consent screen, so it should read as what the user is agreeing to, not as an internal note."},{"n":"permissions","r":true,"t":"array","re":"Permission","info":"Individual Permissions bundled into this scope, in addition to anything reached via areas. Use this for permissions that must be granted without pulling in their whole Area. Empty array is the minimum default."}],"service":"APR Access","ext":"LookupEntity","notes":"Rebased onto LookupEntity 01 Sep 2026 (delete-and-recreate, since extends is fixed at creation), together with Permission, Area and Role. The former scopeId string was dropped in favour of the inherited UUID id per no-redundant-entity-id; code and name now come from the base schema. Scope was already named in the company-scoping-required control-plane exemption set and remains company-null under tenant-scoped-dictionary-declares-company.\n\nNAME COLLISION, worth restating because it causes real confusion: the 'scope' segment in a permission code (portal:PRODUCT:read) names the resource and has nothing to do with this entity. Renaming the grammar segment to 'resource' should be considered.\n\nINTERSECTION SEMANTICS: the cross-entity rule that a scope bounds rather than grants is stated here because it is the failure mode most likely to be inverted in implementation — treating scope as an additive grant would let a client escalate past the user's Roles by asking for more.","related":["Area","Permission","Client"],"bv":{"rules":[{"rule":"code is required and globally unique — it is the literal scope string presented in the OAuth2 authorization request, so it must be stable for the life of every issued token and refresh token.","when":"create","field":"code","severity":"error"},{"rule":"isReadOnly must be true on every seeded Scope. Scopes are authored by migration and system processes, not by tenant users.","when":"create","field":"isReadOnly","severity":"error"},{"rule":"A Scope is deactivated (isActive false), never deleted. Issued tokens and stored client grants carry the scope string with no foreign key, so deleting the record silently changes what those tokens mean instead of failing.","when":"delete","field":"isActive","severity":"error"},{"rule":"areas and permissions must not both be empty — a scope granting nothing is presented on a consent screen as if it grants something.","when":"create","field":"areas","severity":"error"}],"lifecycle":null,"calculations":[{"name":"permissionCount","formula":"COUNT(DISTINCT permissions ∪ permissions of each area in areas)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Narrows the applicability of a Permission to a resource subset. A scope bounds the permissions a token may carry; it never grants a permission the holder's Roles do not already confer. Effective access is the intersection of scope and role grants, never the union.","entity":"Permission"},{"rule":"Scope grants are held per Client, so the same scope may be available to one Client and not another.","entity":"Client"}]}},{"name":"Season","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Time-bound merchandising period defining when products are available or relevant (e.g. Spring 2026, Holiday).","status":"draft","properties":[{"n":"code","r":true,"t":"string","u":true,"info":"Human-readable key. Unique within scope."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. The seasons a business buys for are its own trading vocabulary — 'Holiday' starts on different dates for different retailers."},{"n":"endDate","t":"date"},{"n":"months","r":true,"t":"array","v":["January","February","March","April","May","June","July","August","September","October","November","December"],"info":"Calendar months included in this season."},{"n":"name","r":true,"t":"string","u":true},{"n":"seasonId","r":true,"t":"string"},{"n":"startDate","t":"date"}],"ext":"LookupEntity","related":["Product"],"bv":{"rules":[{"rule":"Must be unique within the Company","when":"create","field":"Code","severity":"error"}],"lifecycle":null,"calculations":[{"name":"productCount","formula":"COUNT(Product WHERE seasons CONTAINS this)","trigger":"query-time"}],"crossEntityConstraints":[]}},{"name":"Secret","class":"Core","subsystem":"CONFIG","area":"Configuration","desc":"An encrypted sensitive value (API key, connection string, certificate). Referenced by Configs; never stored in plain text.","status":"draft","properties":[{"n":"actor","r":true,"t":"entityRef","re":"Actor"},{"n":"encryptedValue","r":true,"t":"encrypted","info":"AES-256 encrypted secret value. Decrypted only at point of use."},{"n":"environment","r":true,"t":"entityDetail","re":"Environment"},{"n":"expiresAt","t":"datetime"},{"n":"name","r":true,"t":"string"},{"n":"rotationPolicy","t":"schema"},{"n":"secretId","r":true,"t":"string"}],"service":"APR Config","shopify":"API keys and access tokens","related":["Actor","Environment","Config"],"bv":{"rules":[{"rule":"Must be stored encrypted at rest (AES-256 or equivalent)","when":"always","field":"Value","severity":"error"},{"rule":"Must never appear in logs, API responses, or error messages","when":"always","field":"Value","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Scoped to a specific Environment","entity":"Environment"}]}},{"name":"Ship Order","class":"Order","subsystem":"CONNECT","area":"Sales & Orders","desc":"Fulfillment instruction derived from an Order; directs picking, packing, and shipping from a specific location.","status":"stub","properties":[{"n":"fulfillmentOrder","t":"entityRef","re":"Fulfillment Order"},{"n":"lines","r":true,"t":"array","info":"Array of ShipOrderLine sub-documents. Each line specifies a quantity to ship for an item."},{"n":"location","r":true,"t":"entityDetail","re":"Location"},{"n":"rejectReason","t":"entityDetail","re":"Ship Reject Reason"},{"n":"salesOrder","r":true,"t":"entityRef","re":"Sales Order"},{"n":"shipOrderId","r":true,"t":"string"}],"ext":"OperationalDocument","shopify":"FulfillmentOrder → Fulfillment workflow","related":["Sales Order","Fulfillment Order","Shipment","Location"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Must reference a valid Fulfillment Order","entity":"Fulfillment Order"}]}},{"name":"Ship Reject Reason","class":"Dictionary","subsystem":"CONNECT","area":"Sales & Orders","desc":"Codified reason for rejecting or canceling a ship order (out of stock, address issue, customer request).","status":"stub","properties":[{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. Reason codes are the tenant's own operational vocabulary and drive its reporting categories."},{"n":"description","t":"string"},{"n":"name","r":true,"t":"string","u":true}],"ext":"LookupEntity","related":["Ship Order"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Shipment","class":"Operational","subsystem":"CONNECT","area":"Sales & Orders","desc":"Physical dispatch of goods against a Ship Order, broken into Cartons with carrier tracking.","status":"draft","properties":[{"n":"canShipPartial","r":true,"t":"boolean","info":"Whether this shipment allows partial delivery."},{"n":"carrier","t":"string"},{"n":"cartons","r":true,"t":"array","re":"Shipment Carton"},{"n":"deliveredDate","t":"datetime"},{"n":"deliveryInfo","t":"schema","info":"Delivery scheduling and routing information."},{"n":"fillLocation","r":true,"t":"entityDetail","re":"Location","info":"Location fulfilling this shipment."},{"n":"fulfillmentMethod","r":true,"t":"enumeration","v":["ShipToCustomer","StorePickup","ShipToStore","SameDay"],"info":"How this shipment will be delivered."},{"n":"isArchived","r":true,"t":"boolean","info":"Soft archive flag."},{"n":"isDropShip","r":true,"t":"boolean","info":"Whether this is a drop-ship directly from vendor."},{"n":"lines","r":true,"t":"array","info":"Shipment line items with quantities and status."},{"n":"sale","t":"entityRef","re":"Sale","info":"The Sale recording revenue for this shipment, where one exists. Replaced the bare-string saleId on 22 Sep 2026, matching SalesOrderLine.sale."},{"n":"salesOrder","r":true,"t":"entityRef","re":"Sales Order","info":"The originating Sales Order. Replaced the bare-string salesOrderId on 22 Sep 2026 — the registry expresses cross-document references by type, not by an Id-suffixed string."},{"n":"shipmentNo","r":true,"t":"string","u":true,"info":"Human-readable identifier for the shipment. Required and unique within Company, per entity-no-property convention. Renamed from shipmentId on 22 Sep 2026 — the Id suffix implied a system identifier, which the inherited id already provides."},{"n":"shipMethod","t":"entityDetail","re":"Shipping Method","info":"Shipping method used."},{"n":"shipOrder","r":true,"t":"entityRef","re":"Ship Order"},{"n":"shippedDate","t":"datetime"},{"n":"shipToAddress","r":true,"t":"valueType","vt":"Address","info":"Address value type. A STATIC SNAPSHOT of the destination the goods were actually dispatched to, copied when the shipment is created rather than referenced from the Customer's address book. Changed from an inline schema (which was never defined) to the Address value type on 22 Sep 2026 so it shares one shape with SalesOrderLine.shipToAddress and Purchase Order.dropShipAddress, the addresses it is dispatched from."},{"n":"shipToCustomer","r":true,"t":"entityRef","re":"Customer","info":"The Customer receiving the shipment. Live reference for service, returns and history; paired with the shipToAddress snapshot, which records where the goods were actually sent. Neither replaces the other. Replaced the bare-string shipToCustomerId on 22 Sep 2026 to match SalesOrderLine.shipToCustomer and Purchase Order.dropShipCustomer."},{"n":"status","r":true,"t":"schema","info":"ShipmentStatus inline schema with documentStatus and operationalStatus."},{"n":"trackingNo","t":"string"}],"ext":"OperationalDocument","shopify":"Fulfillment resource with tracking","related":["Ship Order","Shipment Carton","Customer","Sales Order","Sale","Location","Shipping Method","Fulfillment Order"],"bv":{"rules":[],"lifecycle":{"states":["Open","Rejected","Completed"],"transitions":[{"to":"Completed","from":"Open","conditions":["Delivery confirmed or picked up"]},{"to":"Rejected","from":"Open","conditions":["Shipment refused by customer"]}],"initialState":"Open"},"calculations":[],"operationalStates":["PrepareShipment","ReadyToShip","Shipped","ReadyForPickup","PickedUp","ReadyForDropShip","DropShipComplete","PartiallyRejected","PreparePickup","Processing"],"crossEntityConstraints":[{"rule":"Completion updates parent Sales Order line status","entity":"Sales Order"},{"rule":"Must reference a valid Fulfillment Order","entity":"Fulfillment Order"}]},"inlineSchemas":[{"name":"ShipLineStatus","extends":"OperationalSubDocument","properties":[{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"},{"info":"Status of this shipment line.","name":"documentStatus","type":"enumeration","values":["Open","Completed","Cancelled"],"required":true}]},{"name":"ShipmentStatus","extends":"OperationalSubDocument","properties":[{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"},{"info":"Current document lifecycle status of the shipment.","name":"documentStatus","type":"enumeration","values":["Open","Rejected","Completed"],"required":true},{"info":"Operational sub-status for workflow tracking.","name":"operationalStatus","type":"enumeration","values":["PrepareShipment","ReadyToShip","Shipped","ReadyForPickup","PickedUp","ReadyForDropShip","DropShipComplete","PartiallyRejected","PreparePickup","Processing"]}]}]},{"name":"Shipment Carton","class":"Operational","subsystem":"CONNECT","area":"Sales & Orders","desc":"Individual carton or package within a multi-package shipment, tracking contents at box level.","status":"stub","properties":[{"n":"cartonId","r":true,"t":"string"},{"n":"cartonNo","t":"string"},{"n":"lines","r":true,"t":"array","info":"Array of ShipmentLine sub-documents. Each line records items packed into this carton."},{"n":"shipment","r":true,"t":"entityRef","re":"Shipment"},{"n":"trackingNo","t":"string"},{"n":"weight","t":"decimal"}],"ext":"OperationalSubDocument","related":["Shipment"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Shipping Method","class":"Dictionary","subsystem":"CONNECT","area":"Sales & Orders","desc":"Carrier service or delivery method available for fulfilment (e.g. Standard Ground, Express, Click & Collect).","status":"stub","properties":[{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. Carriers are facts about the world; which services this tenant offers, under what names, is not."},{"n":"daysInTransit","t":"integer","info":"Estimated transit time in business days."},{"n":"name","r":true,"t":"string","u":true}],"ext":"LookupEntity","related":["Ship Order","Sales Order"]},{"name":"Stock Adjustment","class":"Transactional","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"Records an inventory correction with a reason code (shrinkage, receiving error, etc.).","status":"stub","properties":[{"n":"adjustmentAction","r":true,"t":"enumeration","v":["Adjust","Restate"],"info":"Adjust (delta change) or Restate (absolute override)."},{"n":"adjustmentNo","r":true,"t":"string","info":"Human-readable adjustment number / identifier."},{"n":"effectiveDate","t":"datetime","info":"Date the adjustment takes effect for reporting purposes. When different from inherited transactionDate, used for fiscal period alignment."},{"n":"fiscalDate","t":"datetime","info":"Fiscal period date for financial reporting."},{"n":"lines","r":true,"t":"array","info":"Array of StockAdjustmentLine sub-documents."},{"n":"location","r":true,"t":"entityDetail","re":"Location","info":"The location (store/warehouse) where inventory is being adjusted."},{"n":"reason","t":"entityDetail","re":"Stock Adjustment Reason","info":"Reason code for the adjustment (shrinkage, damage, correction, etc.)."},{"n":"status","r":true,"t":"schema","info":"Lifecycle status of the stock adjustment. Tracks current state and who/when it was last changed."},{"n":"stockBin","t":"entityRef","re":"StockBin","info":"The specific bin within the location where inventory is being adjusted."},{"n":"stockCountId","t":"string","info":"Reference to the originating Stock Take if this adjustment was generated from a count."},{"n":"totalCost","t":"decimal","info":"Calculated: sum of all line extended costs."},{"n":"totalLines","t":"decimal","info":"Calculated: count of lines on the adjustment."},{"n":"totalQty","t":"decimal","info":"Calculated: sum of all line quantities."}],"ext":"TransactionalDocument","shopify":"InventoryAdjustment operations","related":["Location","StockBin","Stock Adjustment Reason","Stock Ledger","Item Stock","Stock Take"],"bv":{"rules":[{"rule":"Must reference a valid Reason Code","when":"always","field":"reason","severity":"error"},{"rule":"Must be Adjust or Restate","when":"always","field":"adjustmentAction","severity":"error"},{"rule":"Only physical products (Style or Single class) can be added to a Stock Adjustment","when":"line-add","field":"lines","severity":"error"}],"lifecycle":{"states":["Draft","Processing","Posted"],"transitions":[{"to":"Processing","from":"Draft","conditions":["At least one line exists","Reason code is set"]},{"to":"Posted","from":"Processing","conditions":["Stock Ledger entries written","Item Stock updated"]}],"initialState":"Draft"},"calculations":[{"name":"totalCost","formula":"Sum of line extCost values","trigger":"On line add/edit/remove"},{"name":"totalLines","formula":"Count of lines","trigger":"On line add/remove"},{"name":"totalQty","formula":"Sum of line qty values","trigger":"On line add/edit/remove"}],"crossEntityConstraints":[{"rule":"Posting writes one Stock Ledger entry per line","entity":"Stock Ledger"},{"rule":"Posting updates Item Stock SOH","entity":"Item Stock"}]},"inlineSchemas":[{"name":"StockAdjustmentStatus","schema":"schemas/stock-adjustment/StockAdjustmentStatus.ts","properties":[{"info":"Current lifecycle state of the adjustment.","name":"status","type":"enumeration","values":["Draft","Processing","Posted"],"required":true},{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"}]}]},{"name":"Stock Adjustment Reason","class":"Dictionary","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"Codified reason for inventory adjustments (e.g. damage, theft, correction). Required for audit trails.","status":"stub","properties":[{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company — the convention names 'the reasons a business adjusts stock' as its example of tenant vocabulary. These codes group shrink in Financial Summary, so they are a reporting contract as well as a label."},{"n":"description","t":"string"},{"n":"isShrinkage","r":true,"t":"boolean"},{"n":"name","r":true,"t":"string","u":true}],"ext":"LookupEntity","related":["Stock Adjustment"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Stock Cost Layer","class":"Balance","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"Mutable consumption state for FIFO/LIFO inventory valuation — one layer (lot) per inbound Stock Ledger entry, maintained by the Stock Ledger Service (PRD name: LedgerCost). Outbound movements consume layers in method order (FIFO = oldest acquiredAt first, LIFO = newest first), decrementing remainingQty; the consumed layers' unitCost values determine COGS on the outbound ledger entry. Not used under WAC, which keeps a running average on Inventory Position instead. Unlike ledger entries, layers are mutable (remainingQty / isExhausted change) but are never deleted — exhausted layers are retained for audit and cost-recalculation replay.","status":"draft","properties":[{"n":"acquiredAt","r":true,"t":"datetime","info":"Business timestamp of the inbound movement that created this layer (source entry's entryDate). Consumption ordering key: FIFO consumes oldest acquiredAt first, LIFO newest first. Immutable."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Always matches the source ledger entry's company."},{"n":"currencyCode","r":true,"t":"string","info":"ISO 4217 currency code for unitCost. Inherited from the source ledger entry."},{"n":"exchangeRate","t":"decimal","info":"Exchange rate to the Company's base currency, carried from the source ledger entry at creation. Null when currencyCode equals the base currency."},{"n":"isExhausted","r":true,"t":"boolean","info":"True when remainingQty = 0. Exhausted layers are skipped during consumption but retained for audit. Default false."},{"n":"item","r":true,"t":"entityRef","re":"Item","info":"The Item (SKU) this layer belongs to."},{"n":"location","r":true,"t":"entityDetail","re":"Location","info":"The Location this layer belongs to."},{"n":"originalQty","r":true,"t":"integer","info":"Quantity received into the layer at creation — the source entry's qty. Immutable."},{"n":"remainingQty","r":true,"t":"integer","info":"Unconsumed quantity: decremented as outbound movements draw down the layer under FIFO/LIFO. Invariant: 0 ≤ remainingQty ≤ originalQty."},{"n":"sourceEntry","r":true,"t":"entityRef","re":"Stock Ledger","info":"The inbound Stock Ledger entry (Receipt, Return, Transfer-in, or positive Adjustment) that created this layer."},{"n":"unitCost","r":true,"t":"decimal","info":"Acquisition unit cost of the layer in currencyCode, from the source entry. Immutable for the life of the layer; determines COGS when the layer is consumed."}],"service":"Stock Ledger Service","ext":"Identifiable","notes":"Documented 2026-07-24. Item Stock (reviewed) already references 'LedgerCost layers' in its unitCost/totalCost calculations; this entity makes that structure explicit under the registry naming conventions (Title Case, no compound-camel service names). unitCost and acquiredAt are immutable after creation — only remainingQty and isExhausted change. Under FIFO/LIFO, Inventory Position.totalCost = Σ(remainingQty × unitCost) across a position's layers.","related":["Item","Location","Stock Ledger","Inventory Position"],"bv":{"rules":[{"rule":"Invariant 0 ≤ remainingQty ≤ originalQty","when":"always","field":"RemainingQty","severity":"error"},{"rule":"unitCost, acquiredAt, originalQty, and sourceEntry are immutable after creation — only remainingQty and isExhausted may change","when":"always","field":"UnitCost","severity":"error"},{"rule":"Layers exist only for Item × Location positions whose costingMethod is FIFO or LIFO","when":"create","field":"Item","severity":"error"},{"rule":"Layers are never deleted; exhausted layers are flagged via isExhausted","when":"always","field":"IsExhausted","severity":"error"}],"lifecycle":null,"calculations":[{"name":"isExhausted","formula":"remainingQty === 0","trigger":"On any consumption that changes remainingQty"}],"crossEntityConstraints":[{"rule":"Created by inbound Stock Ledger entries (Receipt, Return, Transfer-in, positive Adjustment); consumed by outbound entries in method order","entity":"Stock Ledger"},{"rule":"Under FIFO/LIFO, Σ(remainingQty) across a position's layers must equal Inventory Position.qtyOnHand, and Σ(remainingQty × unitCost) must equal Inventory Position.totalCost","entity":"Inventory Position"}]}},{"name":"Stock Ledger","class":"Ledger","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"Immutable, append-only audit trail of all inventory quantity and cost changes — the system of record for stock movement history. Each entry records a signed quantity movement and its cost impact for an Item at a Location, with a running balance (balanceQty) and a per-Item × Location sequence (ledgerLine). Written by Sale, Stock Transfer, Stock Adjustment, and Goods Receipt (Purchase) transactions, one entry per document line. The Stock Ledger Service folds entries into Inventory Position (running balance/valuation) and, under FIFO/LIFO, maintains Stock Cost Layer consumption state. Corrections are posted as compensating entries via reversalOf, never edits. Inherited entryDate is the business posting timestamp; inherited sourceEntityType/sourceEntityId identify the triggering document.","status":"draft","properties":[{"c":true,"n":"balanceQty","r":true,"t":"integer","info":"Running on-hand quantity for this Item × Location immediately after this entry, computed in ledgerLine order. Enables point-in-time balance queries and reconciliation against Item Stock SOH without replaying the full ledger."},{"c":true,"n":"baseExtendedCost","t":"decimal","info":"extendedCost converted to the Company's base/reporting currency using the exchangeRate captured at posting. Null when currencyCode equals Company.baseCurrencyCode. Feeds Inventory Position.baseTotalCost and cross-location consolidated valuation."},{"n":"costingMethod","r":true,"t":"enumeration","v":["FIFO","LIFO","WAC"],"info":"Inventory costing method used to value this movement and calculate COGS. Effective method for the Item × Location at posting time (see Inventory Position.costingMethod). Enum aligned with Item Stock: WAC = weighted average cost (renamed from WeightedAverage 2026-07-24)."},{"n":"currencyCode","r":true,"t":"string","info":"ISO 4217 currency code for unitCost and extendedCost. Mirrors Item Stock.currencyCode — typically the Company's base currency but may differ for locations operating in another currency."},{"n":"exchangeRate","t":"decimal","info":"Exchange rate to the Company's base currency, captured at posting time so historical valuation is stable (per decimal-monetary-inherits-currency). Null when currencyCode equals the base currency."},{"c":true,"n":"extendedCost","t":"decimal","info":"Qty × unitCost — signed total cost impact of this movement in currencyCode. Positive for inbound, negative for outbound."},{"n":"item","r":true,"t":"entityRef","re":"Item"},{"c":true,"n":"ledgerLine","r":true,"t":"integer","info":"Monotonic, gap-free sequence per Item × Location, assigned by the Stock Ledger Service at posting. Ordering key for balance replay, reconciliation, and idempotent projection (Inventory Position.lastLedgerLine high-water mark)."},{"n":"location","r":true,"t":"entityDetail","re":"Location"},{"n":"movementType","r":true,"t":"enumeration","v":["Sale","Receipt","Adjustment","Transfer","Return"],"info":"Categorizes the type of inventory movement. The inherited sourceEntityType identifies the specific entity class (e.g. 'Sale', 'Stock Transfer'); movementType provides a higher-level category for reporting."},{"n":"pairedEntry","t":"entityRef","re":"Stock Ledger","info":"For Transfer movements, the counterpart entry at the other location (negative at source ↔ positive at destination; direction is the qty sign). Null for all other movement types."},{"n":"qty","r":true,"t":"integer","info":"Signed quantity change (positive = stock in, negative = stock out)"},{"n":"reversalOf","t":"entityRef","re":"Stock Ledger","info":"Compensating-entry back-reference. Ledger entries are immutable (ledger-entry-immutability convention); corrections and voids post a new entry with negated qty pointing at the original. Null for normal entries."},{"n":"sourceLineId","t":"string","info":"UUID of the specific line item within the source document — entries post per line (e.g. one entry per Sale line). Complements inherited sourceEntityType/sourceEntityId, which identify the document."},{"n":"unitCost","t":"decimal","info":"Unit cost of the movement in currencyCode. Inbound movements (Receipt, Return, Transfer-in, positive Adjustment) carry acquisition/source cost; outbound movements carry the method-resolved COGS cost (FIFO = oldest remaining Stock Cost Layer, LIFO = newest, WAC = running average). Null only for zero-cost corrections."}],"service":"Stock Ledger Service","ext":"LedgerEntry","notes":"Property set expanded from stub on 2026-07-24. Key decisions: (1) costingMethod enum aligned to Item Stock — WeightedAverage renamed WAC; canonical enum is FIFO | LIFO | WAC. (2) No postedAt property — Item Stock projection formulas that reference 'LedgerEntry.postedAt' map to the inherited entryDate (business event timestamp); createdDate is the system write time, and out-of-order postings are tolerated by projections via MAX(stored, incoming) per Item Stock notes. (3) Currency/FX follows the decimal-monetary-inherits-currency convention: currencyCode is required, exchangeRate is captured at posting so historical valuation is stable; baseExtendedCost feeds consolidated (base-currency) valuation. (4) Transfers post paired entries — negative at source, positive at destination — linked via pairedEntry; direction is the qty sign. (5) movementType stays coarse (Sale, Receipt, Adjustment, Transfer, Return); the specific document class lives in sourceEntityType. (6) company is inherited from LedgerEntry (tenant partition key).","related":["Item","Location","Item Stock","Sale","Stock Transfer","Stock Adjustment","Goods Receipt","Inventory Position","Stock Cost Layer"],"bv":{"rules":[{"rule":"Append-only — entries must never be modified or deleted (immutable audit trail); corrections post a new compensating entry via reversalOf","when":"always","field":"LedgerLine","severity":"error"},{"rule":"Must be non-zero","when":"create","field":"Qty","severity":"error"},{"rule":"Must be a valid ISO 4217 code and match the Location's operating currency","when":"create","field":"CurrencyCode","severity":"error"},{"rule":"Required when currencyCode differs from Company.baseCurrencyCode; null otherwise","when":"create","field":"ExchangeRate","severity":"error"},{"rule":"Required (and only allowed) when movementType = 'Transfer' — must reference the counterpart entry for the same transfer with negated qty","when":"create","field":"PairedEntry","severity":"error"},{"rule":"When set, must reference an entry for the same Item × Location, and qty must be the negation of the referenced entry's qty","when":"create","field":"ReversalOf","severity":"error"},{"rule":"Required for inbound movements (qty > 0); for outbound movements it is resolved by the costing method at posting time","when":"create","field":"UnitCost","severity":"warning"}],"lifecycle":null,"calculations":[{"name":"ExtendedCost","formula":"Qty × UnitCost","trigger":"On ledger entry creation"},{"name":"BaseExtendedCost","formula":"ExtendedCost × ExchangeRate (null when currencyCode = Company.baseCurrencyCode)","trigger":"On ledger entry creation"},{"name":"LedgerLine","formula":"Previous max ledgerLine for Item × Location + 1 (monotonic, gap-free)","trigger":"On ledger entry creation (assigned by Stock Ledger Service)"},{"name":"BalanceQty","formula":"Previous balanceQty for Item × Location + Qty, in ledgerLine order","trigger":"On ledger entry creation"},{"name":"UnitCost (outbound)","formula":"Method-dispatched COGS: FIFO = oldest remaining Stock Cost Layer, LIFO = newest remaining layer, WAC = Inventory Position running average","trigger":"On posting of an outbound (negative qty) movement"}],"crossEntityConstraints":[{"rule":"Running quantity sum must match corresponding Item Stock on-hand balance","entity":"Item Stock"},{"rule":"Sale posting creates a negative-qty ledger entry per line item","entity":"Sale"},{"rule":"Goods Receipt posting creates a positive-qty ledger entry per received item","entity":"Goods Receipt"},{"rule":"Transfer creates paired entries: negative at source, positive at destination, linked via pairedEntry","entity":"Stock Transfer"},{"rule":"Adjustment creates a signed entry per adjusted item with reason code","entity":"Stock Adjustment"}]},"inheritance":{"inherited":["id","entryDate","sourceEntityType","sourceEntityId","createdBy","createdDate","company"]}},{"name":"Stock Limit Group","class":"Dictionary","subsystem":"CONNECT","area":"Merchandising","desc":"A named set of minimum and maximum stock thresholds and reorder parameters, applied to a set of items or locations. Despite the name, this is replenishment POLICY rather than inventory state: it says what stock levels the business intends to hold, where Item Stock and Inventory Position say what it actually holds. That is why it sits in Merchandising and they do not.","status":"stub","properties":[{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. Stock thresholds are the tenant's own replenishment policy."},{"n":"name","r":true,"t":"string","u":true}],"ext":"LookupEntity","notes":"Filed under Merchandising 03 Sep 2026, moved from Inventory & Allocation. Still a stub: two properties (code, name) inherited-shape only, and none of the thresholds the description promises are declared — no minQty, maxQty, reorderPoint, reorderQty, or target weeks of supply, and nothing states whether limits are set per item, per location, or per item × location. Replenishment Plan snapshots its policy at run time and would need to snapshot these too; until the properties exist, the relationship between a Stock Limit Group and a Replenishment Plan line cannot be stated.","related":["Item Stock","Item","Location","Replenishment Plan"]},{"name":"Stock Take","class":"Operational","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"A physical count cycle counting event against one or more locations.","status":"stub","properties":[{"n":"countedQty","t":"integer"},{"n":"lines","r":true,"t":"array","info":"Array of StockTakeLine sub-documents. Each line records the expected vs. counted quantity for one Item at the count location."},{"n":"location","r":true,"t":"entityDetail","re":"Location"},{"n":"stockTakeId","r":true,"t":"string"}],"ext":"OperationalDocument","related":["Item","Location","Stock Adjustment"],"bv":{"rules":[],"lifecycle":{"states":["Draft","InProgress","Completed","Posted"],"transitions":[{"to":"InProgress","from":"Draft","conditions":["At least one count line exists"]},{"to":"Completed","from":"InProgress","conditions":["All lines have been counted"]},{"to":"Posted","from":"Completed","conditions":["Variance review approved"]}],"initialState":"Draft"},"calculations":[{"name":"Variance","formula":"CountedQty - ExpectedQty per line","trigger":"On count entry"},{"name":"lineCount","formula":"COUNT(lines)","trigger":"query-time"},{"name":"varianceLineCount","formula":"COUNT(lines WHERE countedQty != expectedQty)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Posting a Stock Take generates Stock Adjustments for variances","entity":"Stock Adjustment"},{"rule":"Must reference a valid active Location","entity":"Location"}]}},{"name":"Stock Transfer","class":"Transactional","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"Records the actual movement of stock between locations (fulfills a Transfer Order).","status":"draft","properties":[{"n":"appliedDiscrepancyRule","t":"enumeration","v":["UseInQty","UseOutQty"],"info":"Corrective action applied when in/out quantities don't match."},{"n":"cartons","r":true,"t":"array","re":"Stock Transfer Carton"},{"n":"destinationLocation","r":true,"t":"entityDetail","re":"Location","info":"The destination location receiving the transferred stock."},{"n":"fiscalDate","t":"datetime","info":"Fiscal date for accounting period assignment."},{"n":"hasDiscrepancy","r":true,"t":"boolean","info":"Whether there is a quantity discrepancy between out and in."},{"n":"isReviewed","r":true,"t":"boolean","info":"Whether discrepancies have been reviewed."},{"n":"lines","r":true,"t":"array","info":"Array of StockTransferLine sub-documents. Each line records an Item, quantity, and cost for the transfer movement."},{"n":"originLocation","r":true,"t":"entityDetail","re":"Location","info":"The origin location sending the transferred stock."},{"n":"postedInDate","t":"datetime","info":"Date the receiving side was posted."},{"n":"postedOutDate","t":"datetime","info":"Date the sending side was posted."},{"n":"purchaseOrderId","t":"string","info":"Associated purchase order, if transfer is PO-driven."},{"n":"reason","t":"entityDetail","re":"Stock Transfer Reason","info":"Reason for the transfer."},{"n":"receivedDate","t":"datetime"},{"n":"reversedStockTransferId","t":"string","info":"ID of the stock transfer this reverses."},{"n":"reversingStockTransferId","t":"string","info":"ID of the stock transfer that reverses this one."},{"n":"shippedDate","t":"datetime"},{"n":"status","r":true,"t":"schema","info":"StockTransferStatus inline schema with documentStatus and operationalStatus."},{"n":"stockTransferNo","r":true,"t":"string"},{"n":"transferDate","r":true,"t":"datetime","info":"Date the transfer was initiated."},{"n":"transferOrder","r":true,"t":"entityRef","re":"Transfer Order"}],"ext":"TransactionalDocument","shopify":"Transfer resource","related":["Transfer Order","Location","Stock Transfer Carton","Stock Ledger"],"bv":{"notes":"Supports reversal via Reversed/Reversal operational status","rules":[{"rule":"Only physical products (Style or Single class) can be added to a Stock Transfer","when":"line-add","field":"lines","severity":"error"}],"lifecycle":{"states":["Draft","InTransit","Processing","Posted","Cancelled"],"transitions":[{"to":"InTransit","from":"Draft","conditions":["Items picked and dispatched from source"]},{"to":"Processing","from":"InTransit","conditions":["Received at destination"]},{"to":"Posted","from":"Processing","conditions":["Stock Ledger entries written for both locations"]},{"to":"Cancelled","from":"Draft","conditions":[]},{"to":"Cancelled","from":"InTransit","conditions":["Items returned to source location"]}],"initialState":"Draft"},"calculations":[{"name":"lineCount","formula":"COUNT(lines)","trigger":"query-time"},{"name":"cartonCount","formula":"COUNT(cartons)","trigger":"query-time"},{"name":"qtyDifference","formula":"SUM(lines.qtyOut) - SUM(lines.qtyIn)","trigger":"On line quantity change"},{"name":"qtyIn","formula":"SUM(lines.qtyIn)","trigger":"On line quantity change"},{"name":"qtyInTransit","formula":"SUM(lines.qtyOut) - SUM(lines.qtyIn)","trigger":"On line quantity change"},{"name":"qtyOut","formula":"SUM(lines.qtyOrdered)","trigger":"On line quantity change"},{"name":"taxAmount","formula":"SUM(lines.taxAmount)","trigger":"On line tax change"},{"name":"totalAmount","formula":"SUM(lines.totalAmount)","trigger":"On line total change"},{"name":"totalCartons","formula":"COUNT(cartons)","trigger":"query-time"},{"name":"totalCost","formula":"SUM(lines.extCost)","trigger":"On line cost change"},{"name":"totalLines","formula":"COUNT(lines)","trigger":"query-time"},{"name":"totalQty","formula":"SUM(lines.qty)","trigger":"On line quantity change"}],"crossEntityConstraints":[{"rule":"Must reference a valid Transfer Order","entity":"Transfer Order"},{"rule":"Posting writes debit entry at source and credit entry at destination","entity":"Stock Ledger"},{"rule":"Posting decrements source and increments destination SOH","entity":"Item Stock"}]},"inlineSchemas":[{"name":"ShipLineStatus","extends":"OperationalSubDocument","properties":[{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"},{"info":"Status of this transfer line.","name":"documentStatus","type":"enumeration","values":["Open","Completed","Cancelled"],"required":true}]},{"name":"StockTransferStatus","extends":"OperationalSubDocument","properties":[{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"},{"info":"Current document lifecycle status.","name":"documentStatus","type":"enumeration","values":["Draft","InTransit","Processing","Posted","Cancelled"],"required":true},{"info":"Operational sub-status for reversal tracking.","name":"operationalStatus","type":"enumeration","values":["Reversed","Complete","Reversal"]}]}]},{"name":"Stock Transfer Carton","class":"Operational","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"Physical shipping container associated with a stock transfer, tracking items packed per container.","status":"stub","properties":[{"n":"cartonId","r":true,"t":"string"},{"n":"cartonNo","t":"string"},{"n":"lines","r":true,"t":"array","info":"Array of StockTransferLine sub-documents within this carton."},{"n":"stockTransfer","r":true,"t":"entityRef","re":"Stock Transfer"},{"n":"weight","t":"decimal"}],"ext":"OperationalSubDocument","related":["Stock Transfer"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Stock Transfer Reason","class":"Dictionary","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"Codified reason for initiating inventory transfers (rebalancing, replenishment, consolidation).","status":"stub","properties":[{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. Feeds Financial Summary's transfer summaries, so the code set is part of the tenant's close."},{"n":"description","t":"string"},{"n":"name","r":true,"t":"string","u":true}],"ext":"LookupEntity","related":["Transfer Order"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"Stock Transfer Reject Reason","class":"Dictionary","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"Codified reason for rejecting a stock transfer at the receiving location.","status":"stub","properties":[{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company."},{"n":"description","t":"string"},{"n":"name","r":true,"t":"string","u":true}],"ext":"LookupEntity","related":["Stock Transfer"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}},{"name":"StockBin","class":"Operational","subsystem":"CONNECT","area":"Organization","desc":"A specific storage position within a Location (e.g. aisle-rack-shelf-bin). Inventory transactions reference bins for put-away, picking, counting, and replenishment. Modeled as an independent entity following SAP EWM / Dynamics 365 / NetSuite conventions.","status":"draft","properties":[{"n":"binId","r":true,"t":"string"},{"n":"binType","t":"enumeration","v":["Shelf","Pallet","Bulk Floor","Case Flow","Tote"],"info":"Physical form factor of the bin. Drives capacity rules and putaway logic. Aligned with SAP EWM storage bin types."},{"n":"code","r":true,"t":"string","info":"Human-readable coordinate label encoding the physical position (e.g. 'A-03-02'). Follows a Location-level naming convention of aisle–rack–shelf–position."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope — the hard isolation boundary for queries, permissions, replication and export. Declared directly because this entity extends no base schema that provides it. Always matches the company of its parent Location."},{"n":"description","t":"string"},{"n":"isMixable","r":true,"t":"boolean","info":"Whether the bin can hold multiple SKUs or lots simultaneously. When false, only one SKU/lot per bin (common for high-value or regulated items)."},{"n":"location","r":true,"t":"entityDetail","re":"Location","info":"Parent Location. A StockBin always belongs to exactly one Location."},{"n":"maxVolume","t":"decimal","info":"Maximum volumetric capacity. Enforced during putaway to prevent overfilling."},{"n":"maxWeight","t":"decimal","info":"Maximum weight capacity. Enforced during putaway to prevent overloading."},{"n":"name","t":"string","info":"Display name for the bin (e.g. 'Aisle A – Shelf 3 – Bin 2')."},{"n":"operationalStatus","t":"enumeration","v":["Available","Hold","Quarantine","Damaged","Blocked"],"info":"Operational status controlling whether inventory can be placed or picked. Distinct from Status which represents the lifecycle state."},{"n":"sequence","t":"integer","info":"Sort/pick order within a zone or location. Controls the sequence in which pickers visit bins."},{"n":"status","r":true,"t":"schema","info":"Lifecycle status of the bin. Tracks current state and who/when it was last changed."},{"n":"zone","t":"string","info":"Functional area the bin belongs to (e.g. Receiving, Forward Pick, Reserve, Staging, Quarantine). Controls putaway and picking strategy selection."}],"related":["Location","Item Stock"],"bv":{"rules":[{"rule":"Must be unique within the Location","when":"create","field":"BinId","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Must reference a valid active Location","entity":"Location"},{"rule":"Cannot delete a bin that holds non-zero stock","entity":"Item Stock"}]},"inlineSchemas":[{"name":"StockBinStatus","schema":"schemas/stock-bin/StockBinStatus.ts","properties":[{"info":"Current lifecycle state of the bin.","name":"status","type":"enumeration","values":["Draft","Active","Archived"],"required":true},{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"}]}]},{"name":"Subscription","class":"Core","subsystem":"PLATFORM","area":"Licensing","desc":"The commercial contract between All Point and a subscriber. Organization is already defined as \"the subscriber — the entity that holds a contract with All Point\", so the contract itself hangs off Organization, one level above the tenant boundary. Subscription is the COMMERCIAL layer of licensing; Licensing Tier is the CATALOGUE layer (what is sellable); LicenseGrant is the ENTITLEMENT layer (what a given installation actually holds). Keeping the three apart is the whole point of the model: a subscriber signs one contract, that contract funds many grants across many Companies and many products, and each grant is enforced at its own installation. Subscription also owns the two commercial facts that are genuinely account-wide rather than per-product: whether the subscriber is entitled to Dedicated infrastructure (deploymentEntitlement, which gates Company.deploymentModel), and the billing period every grant renews against. CONTROL PLANE: Subscription describes the commercial relationship, not tenant data, so it extends CoreEntity and carries no company scoping — it sits with Organization, Client and Application in the control-plane exemption set.","status":"draft","properties":[{"n":"billingCycle","r":true,"t":"enumeration","v":["monthly","quarterly","annual","custom"],"info":"The period the contract bills and renews on. custom covers negotiated enterprise terms whose actual dates live in currentPeriodStart / currentPeriodEnd rather than being derivable from the cycle."},{"n":"billingStatus","r":true,"t":"enumeration","v":["current","past_due","in_dunning","written_off"],"info":"SYSTEM-OBSERVED STATE — the actual payment state, written only by the billing system. Never set by an operator. Mirrors the intent/state split already used on Application Installation (status vs authStatus) and Connection. The two are independent and must not be derived from each other: a missed payment does not move status off 'active', and cancelling a contract does not clear an outstanding balance. A subscription in past_due or in_dunning is still status 'active' — whether that suspends the downstream grants is a dunning policy decision, expressed by the billing system writing licenseStatus on the grants, not by mutating status here."},{"n":"currentPeriodEnd","t":"datetime","info":"End of the current billing period. The default expiry that LicenseGrants funded by this Subscription inherit when they do not declare their own expiresAt."},{"n":"currentPeriodStart","t":"datetime","info":"Start of the current billing period."},{"n":"deploymentEntitlement","r":true,"t":"enumeration","v":["shared","dedicated"],"info":"Whether this contract entitles the subscriber to isolated infrastructure. This is the property that the phrase \"provisioned at an increased licensing tier\" in the Company and Deployment Model descriptions was reaching for. Infrastructure isolation is an account-wide commercial fact — it is bought once, for the subscriber, and is not a per-application entitlement — so it belongs here rather than in a LicenseGrant. It GATES but does not SET Company.deploymentModel: a Company may only be provisioned Dedicated when its Organization's Subscription carries 'dedicated', and the actual provisioning decision remains Company-level, since different Companies under one Organization may sit on different Clients."},{"n":"endDate","t":"date","info":"Contract end date, where the term is fixed. Null for an evergreen contract that renews until cancelled. Distinct from currentPeriodEnd, which is the end of the current billing period within the term."},{"n":"organization","r":true,"t":"entityRef","re":"Organization","info":"The subscriber holding this contract. Many-to-one — an Organization may hold more than one Subscription over time (succession after a renegotiation) and in principle concurrently, though exactly one should be status 'active' at any moment."},{"n":"startDate","r":true,"t":"date","info":"Contract commencement date. Calendar-only: contracts start on a date, not at an instant."},{"n":"status","r":true,"t":"enumeration","v":["draft","active","cancelled","expired"],"info":"OPERATOR INTENT — the commercial state of the contract as a human has set it. Set only by a user or a contract action, never by a payment event. draft = being negotiated, not yet in force. active = in force. cancelled = deliberately terminated. expired = term ended without renewal. Observed payment state lives in billingStatus. Effective funding is the conjunction — a Subscription funds grants only when status is 'active'."},{"n":"subscriptionNo","r":true,"t":"string","u":true,"info":"Human-readable business identifier"}],"ext":"CoreEntity","notes":"Created 15 Sep 2026 alongside Licensing Tier and the LicenseGrant value type, as the third layer of the licensing model. Extends CoreEntity (not BaseDocument) for the same reason Client and Application do: this is control-plane data about the commercial relationship, not tenant data, so BaseDocument's franchiseGroups would be meaningless. Deliberately NOT attached to Company: Organization is already documented as the contracting party, and attaching the contract to the tenant boundary would make a multi-Company subscriber hold N contracts it never signed. Deliberately thin on billing mechanics — no line items, no invoices, no proration. All Point is not the system of record for billing any more than it is for payroll (compare the Commission Plan note); this entity records the terms the platform must ENFORCE, and the billing system remains authoritative for what is charged. If invoice-level detail is ever needed here, model it as a separate entity referencing this one rather than growing this one.","related":["Organization","Company","Licensing Tier","Application Installation","Connection"],"bv":{"rules":[{"rule":"status is commercial intent and is writable only by a user or contract action. No payment event (charge success, charge failure, dunning escalation) may write to status — observed payment state belongs in billingStatus. The converse also holds: billingStatus is written only by the billing system and never by an operator.","when":"always","field":"status","severity":"error"},{"rule":"A Subscription funds LicenseGrants only while status is 'active'. Grants funded by a cancelled or expired Subscription must be moved to licenseStatus 'expired' or 'cancelled' by the billing system rather than silently continuing to resolve — a grant whose funding contract has ended but which still reads as active is the failure mode this layering exists to prevent.","when":"a grant is resolved","field":"status","severity":"error"},{"rule":"deploymentEntitlement gates Company.deploymentModel but does not set it. A Company may be provisioned Dedicated only when its Organization's active Subscription carries 'dedicated'; downgrading a Subscription to 'shared' while a Dedicated Company exists must be refused, not silently applied, because the infrastructure is already provisioned and the correction is a migration, not a field edit.","when":"always","field":"deploymentEntitlement","severity":"error"},{"rule":"At most one Subscription per Organization should be status 'active' at any moment. Overlapping active contracts make grant funding ambiguous. Succession after a renegotiation should move the prior contract to 'expired' in the same transaction that activates the new one.","when":"always","field":"organization","severity":"warning"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Company.deploymentModel 'Dedicated' requires the owning Organization's active Subscription.deploymentEntitlement to be 'dedicated'. This is the replacement for the informal claim, previously carried in the Company and Deployment Model descriptions, that Dedicated is provisioned 'at an increased licensing tier'.","entity":"Company"},{"rule":"Deleting or archiving an Organization must cascade to its Subscriptions, which in turn must expire every LicenseGrant they fund.","entity":"Organization"}]}},{"name":"Tax Class","class":"Dictionary","subsystem":"CONNECT","area":"Products & Pricing","desc":"Tax classification assigned to products determining applicable tax rates and rules. Pure LookupEntity — no additional fields beyond the base.","status":"stub","properties":[{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. Tax law is a fact about the world, but a tax CLASS is this tenant's mapping of its assortment onto that law, and the convention names Tax Class explicitly as tenant-scoped."}],"ext":"LookupEntity","shopify":"tax_code on product variants","related":["Product","Classification"],"bv":{"rules":[{"rule":"Must be unique within the Company","when":"create","field":"Code","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Cannot delete a Tax Class assigned to active Products","entity":"Product"}]}},{"name":"Transfer Order","class":"Order","subsystem":"CONNECT","area":"Inventory & Allocation","desc":"Authorization to move stock between locations before the physical movement occurs.","status":"stub","properties":[{"n":"fromLocation","r":true,"t":"entityDetail","re":"Location"},{"n":"lines","r":true,"t":"array","info":"Array of TransferOrderLine sub-documents. Each line specifies an Item and quantity to transfer between locations."},{"n":"reason","t":"entityDetail","re":"Stock Transfer Reason"},{"n":"toLocation","r":true,"t":"entityDetail","re":"Location"},{"n":"transferOrderId","r":true,"t":"string"}],"ext":"OperationalDocument","shopify":"Transfer (draft status)","related":["Location","Stock Transfer","Stock Transfer Reason"],"bv":{"rules":[{"rule":"Must differ from ToLocation","when":"always","field":"FromLocation","severity":"error"},{"rule":"Only physical products (Style or Single class) can be added to a Transfer Order","when":"line-add","field":"lines","severity":"error"}],"lifecycle":{"states":["Draft","Processing","Open","Complete","Cancelled"],"transitions":[{"to":"Processing","from":"Draft","conditions":["At least one item line exists"]},{"to":"Cancelled","from":"Draft","conditions":[]},{"to":"Open","from":"Processing","conditions":["Stock Transfer created for shipment"]},{"to":"Complete","from":"Open","conditions":["All lines received at destination"]},{"to":"Cancelled","from":"Open","conditions":["No Stock Transfers in transit"]}],"initialState":"Draft"},"calculations":[],"crossEntityConstraints":[{"rule":"Generates one or more Stock Transfers for fulfillment","entity":"Stock Transfer"},{"rule":"Both FromLocation and ToLocation must be valid active Locations","entity":"Location"}]}},{"name":"User","class":"Core","subsystem":"ACCESS","area":"Security & Permissions","desc":"A human identity within the platform — employee, admin, or external partner. Carries credentials, Roles, and Franchise Group assignments. The User's franchiseGroups property drives data sanitization — queries and API responses are filtered to return only documents tagged with the User's assigned Franchise Groups.","status":"draft","properties":[{"n":"applications","r":true,"t":"array","re":"Application","info":"Applications this User is allowed to access. Controls which platform applications (POS, eComm, Admin, etc.) the User can authenticate into. Enforced at login — the User's token is scoped to the intersection of their Roles and allowed Applications."},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"The Company tenant this user belongs to. Defines the hard isolation boundary for queries, permissions, replication, and export."},{"n":"email","r":true,"t":"string","u":true},{"n":"firstName","r":true,"t":"string"},{"n":"franchiseGroups","r":true,"t":"array","re":"Franchise Group","info":"Franchise Groups this User is assigned to. Drives data sanitization — the platform filters all query results and API responses to return only documents whose franchiseGroups overlap the User's assignments, PLUS documents whose franchiseGroups array contains the GLOBAL sentinel (visible to everyone). An empty array means the User is assigned to NO franchise groups and therefore sees ONLY GLOBAL-scoped documents — it does NOT grant access to all data within the Company. This is the sub-tenant data segmentation mechanism within the Company boundary."},{"n":"lastName","r":true,"t":"string"},{"n":"roles","r":true,"t":"array","re":"Role"},{"n":"userId","r":true,"t":"string"},{"n":"userTier","r":true,"t":"schema","info":"The User's position within the franchise hierarchy. Drives UI scoping, agent tool RBAC, and default Franchise Group assignment behavior. Audited (UserTier inline schema) because tier transitions cross trust boundaries — promoting from Store to Franchise grants cross-location visibility, and from Franchise to Corporate grants cross-franchise visibility. Orthogonal to roles (permissions) and franchiseGroups (data sanitization scope). System / service identities are represented via the Actor entity, not via a tier value on User."}],"service":"APR Access","shopify":"Staff and User resources","related":["Application","Role","Company","Employee","Franchise Group"],"bv":{"rules":[{"rule":"Must be unique within the Company and valid email format","when":"always","field":"Email","severity":"error"}],"lifecycle":null,"calculations":[],"crossEntityConstraints":[{"rule":"Must have at least one Role assigned","entity":"Role"},{"rule":"Must belong to a valid Company","entity":"Company"}]},"inlineSchemas":[{"name":"UserTier","schema":"schemas/user/UserTier.ts","properties":[{"info":"Current tier classification within the franchise hierarchy. Corporate = corporate / franchisor staff. Franchise = regional or franchise-owner / multi-unit operator. Store = store-level staff working at a single location.","name":"tier","type":"enumeration","values":["Corporate","Franchise","Store"],"required":true},{"info":"User ID who last changed the tier.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last tier change.","name":"changedDate","type":"datetime"}]}]},{"name":"Vendor","class":"Operational","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"A supplier of goods. Has terms, item-level cost structures, and payment conditions.","status":"draft","properties":[{"n":"accountNos","r":true,"t":"array","info":"Vendor account numbers, one per franchise group with primary flag."},{"n":"contacts","r":true,"t":"array","info":"Vendor contacts with position/title."},{"n":"defaultCurrency","t":"entityDetail","re":"Currency","info":"Default currency for purchase orders to this vendor. Seeds Purchase Order.currency on create; the PO may override it. Renamed from orderCurrency on 22 Sep 2026 (default prefix per naming standard §5; pairs with PO.currency by stripping the prefix)."},{"n":"defaultPaymentTerms","r":true,"t":"array","info":"Default payment terms per franchise group."},{"n":"emails","r":true,"t":"array","info":"Email addresses associated with this vendor."},{"n":"leadTimeDays","t":"integer","info":"Default lead time in days for orders from this vendor."},{"n":"minimumPurchaseAmount","t":"decimal","info":"Minimum purchase amount for orders to this vendor."},{"n":"name","r":true,"t":"string"},{"n":"phones","r":true,"t":"array","info":"Phone numbers associated with this vendor."},{"n":"physicalAddress","t":"valueType","vt":"Address","info":"Vendor's physical/business address."},{"n":"referenceNo","t":"string","info":"External reference number for the vendor."},{"n":"shippingAddress","t":"valueType","vt":"Address","info":"Vendor's shipping/returns address."},{"n":"status","r":true,"t":"schema","info":"VendorStatus inline schema capturing current document status."},{"n":"vendorNo","r":true,"t":"integer"},{"n":"websiteUrl","t":"string","info":"Vendor's website URL. Renamed from vendorWebsiteUrl on 22 Sep 2026 (no redundant entity prefix)."}],"ext":"OperationalDocument","related":["Purchase Order","Vendor Invoice","Vendor Payment Term","Item"],"bv":{"rules":[{"rule":"Must be unique within the Company","when":"create","field":"VendorNo","severity":"error"}],"lifecycle":{"states":["Draft","Active","Inactive","Archived"],"transitions":[{"to":"Active","from":"Draft","conditions":[]},{"to":"Inactive","from":"Active","conditions":["No open POs"]},{"to":"Active","from":"Inactive","conditions":[]},{"to":"Archived","from":"Inactive","conditions":["No outstanding balance"]}],"initialState":"Draft"},"calculations":[{"name":"productCount","formula":"COUNT(DISTINCT Product via VendorItemValue.item.product)","trigger":"query-time"},{"name":"itemCount","formula":"COUNT(DISTINCT Item via VendorItemValue.item)","trigger":"query-time"},{"name":"purchaseOrderCount","formula":"COUNT(Purchase Order WHERE vendor = this)","trigger":"query-time"}],"crossEntityConstraints":[{"rule":"Cannot archive Vendor with open Purchase Orders","entity":"Purchase Order"}]},"inlineSchemas":[{"name":"VendorStatus","extends":"OperationalSubDocument","properties":[{"info":"User ID who last changed the status.","name":"changedBy","type":"string"},{"info":"ISO 8601 timestamp of the last status change.","name":"changedDate","type":"datetime"},{"info":"Current document lifecycle status of the vendor.","name":"documentStatus","type":"enumeration","values":["Draft","Active","Archived"],"required":true}]},{"name":"VendorAccountNo","extends":"OperationalSubDocument","properties":[{"info":"Account number assigned by this vendor.","name":"accountNo","type":"string","required":true},{"info":"Franchise group this account number applies to.","name":"franchiseGroupId","type":"string"},{"info":"Whether this is the primary account number.","name":"isPrimary","type":"boolean","required":true}]},{"name":"VendorDefaultPaymentTerms","extends":"OperationalSubDocument","properties":[{"info":"Franchise group these payment terms apply to.","name":"franchiseGroupId","type":"string"},{"info":"Default payment terms for this franchise group.","name":"paymentTerms","type":"entityDetail","relatedEntity":"Vendor Payment Term"}]}]},{"name":"Vendor Credit","class":"Operational","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"Standalone credit memo received from a Vendor — reduces an outstanding payable. Matched against the original Vendor Invoice or Purchase Order and produces a Bill of type 'credit' when approved. Parallel to Vendor Invoice but represents money owed back to the buyer.","status":"draft","properties":[{"n":"creditNo","r":true,"t":"string","info":"Vendor-assigned credit memo number."},{"n":"lines","r":true,"t":"array","info":"Array of VendorCreditLine sub-documents. Each line records a credited Item, quantity, and cost for matching against the original invoice or PO."},{"n":"reason","t":"string","info":"Reason for the credit — e.g. return, pricing dispute, damaged goods, vendor goodwill."},{"n":"sourceInvoice","t":"entityRef","re":"Vendor Invoice","info":"Optional reference to the originating Vendor Invoice. Null for standalone credits (e.g. rebates, goodwill)."},{"n":"vendor","r":true,"t":"entityRef","re":"Vendor"}],"ext":"OperationalDocument","notes":"Vendor Credits are external input documents (like Vendor Invoices), not system-generated results. They go through their own matching and approval workflow before producing a credit-type Bill.","related":["Vendor","Vendor Invoice","Purchase Order","Bill"],"bv":{"rules":[{"rule":"If linked to a source invoice, credit total must not exceed original invoice total","when":"match","field":"CreditTotal","severity":"error"}],"lifecycle":{"states":["Draft","Matched","Approved","Posted","Disputed"],"transitions":[{"to":"Matched","from":"Draft","conditions":["Matched against source invoice, PO, or standalone approval"]},{"to":"Approved","from":"Matched","conditions":["Approval workflow complete"]},{"to":"Posted","from":"Approved","conditions":["Credit-type Bill generated"]},{"to":"Disputed","from":"Draft","conditions":["Credit amount or line items contested"]},{"to":"Draft","from":"Disputed","conditions":["Dispute resolved, credit corrected"]}],"initialState":"Draft"},"calculations":[],"crossEntityConstraints":[{"rule":"Must reference a valid active Vendor","entity":"Vendor"},{"rule":"When linked, line items should correspond to the original invoice lines","entity":"Vendor Invoice"},{"rule":"Produces a Bill with type 'credit' upon posting","entity":"Bill"}]}},{"name":"Vendor Invoice","class":"Operational","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"Vendor's billing document matched against POs and Goods Receipts. Header/line discounts and fees parallel the PO structure.","status":"draft","properties":[{"n":"invoiceNo","r":true,"t":"string"},{"n":"lines","r":true,"t":"array","info":"Array of VendorInvoiceLine sub-documents. Each line records an invoiced Item, quantity, and cost for three-way matching."},{"n":"vendor","r":true,"t":"entityRef","re":"Vendor"}],"ext":"OperationalDocument","notes":"Header Discounts/Fees are below-the-line. Line Discounts/Fees affect line cost.","related":["Vendor","Purchase Order","Goods Receipt","Purchase"],"bv":{"rules":[{"rule":"Must match PO/Receipt totals within configured tolerance","when":"match","field":"InvoiceTotal","severity":"warning"}],"lifecycle":{"states":["Draft","Matched","Approved","Posted","Disputed"],"transitions":[{"to":"Matched","from":"Draft","conditions":["Three-way match: PO, Receipt, Invoice within tolerance"]},{"to":"Approved","from":"Matched","conditions":["Approval workflow complete"]},{"to":"Posted","from":"Approved","conditions":["Payment scheduled"]},{"to":"Disputed","from":"Draft","conditions":["Variance exceeds tolerance"]},{"to":"Draft","from":"Disputed","conditions":["Dispute resolved, invoice corrected"]}],"initialState":"Draft"},"calculations":[],"crossEntityConstraints":[{"rule":"Three-way match against PO and Purchase","entity":"Purchase Order"},{"rule":"Line quantities must reconcile with received quantities","entity":"Goods Receipt"}]}},{"name":"Vendor Payment Term","class":"Dictionary","subsystem":"CONNECT","area":"Purchasing & Receiving","desc":"Negotiated payment conditions with a vendor (e.g. Net 30, 2/10 Net 30).","status":"stub","properties":[{"n":"code","r":true,"t":"string"},{"n":"company","r":true,"t":"entityDetail","re":"Company","info":"Tenant scope. Redeclared as REQUIRED, overriding LookupEntity's optional company, per tenant-scoped-dictionary-declares-company. Negotiated terms are commercially sensitive and specific to one buyer's relationship with a vendor — of everything in this set, this is the dictionary where a cross-tenant read would be most damaging."},{"n":"description","t":"string"},{"n":"discountDays","t":"integer"},{"n":"discountPercent","t":"decimal"},{"n":"name","r":true,"t":"string","u":true},{"n":"netDays","t":"integer"}],"ext":"LookupEntity","related":["Vendor"],"bv":{"rules":[],"lifecycle":null,"calculations":[],"crossEntityConstraints":[]}}]