Corteksa

Smart catalog field spec

Smart catalog field spec

Delta document. smart_catalog is the catalog picker + line-item builder custom field — it lets a record pick record(s) from a configured SOURCE object (a product/service catalog), optionally as line items (quantity × unit price + VAT), and stores a value-embedded snapshot of that selection as JSONB. It reuses the entire text lifecycle (create → validate → serialize → store → read → update → delete), the shared RLS/access model, and the shared UX skeleton specified in the text field spec (01-text.md). Read that first. It borrows the picker + read-time-enrichment pattern from the select field spec (16-select.md) (a closed set + resolve-on-read; here the "set" is another object's records, not an inline option list) and the money semantics of the currency field spec (22-currency.md) (line totals/subtotal/VAT are currency, so per-currency dp, no mixed-currency sums, snapshot fidelity all apply). This file specifies ONLY what differs — and it re-runs the full Corteksa-wide edge-case map, because smart_catalog is the top-complexity member of the Smart Field Engine: it is the only field type whose value references another object's records, carries commerce math, and must survive the referenced source being deleted mid-quote.

Framing: smart_catalog ships today. Every FR describing current behavior is the backward-compat baseline. Proposals are tagged [NEW] and gated. IDs are file-local (FR-2800+, UX-280x, SEC-280x, SC-<AREA>-NN, T-280x; see suite README). smart_catalog is not relation (FRD-27) — that contrast is a load-bearing part of this document (see §0).


0. Delta-from-text at a glance (the only things that change)

Dimensiontext (FRD-01)smart_catalog (this doc)Rule
PG column typeVARCHAR(255)JSONB (TypeMapperService → jsonb)FR-2800
HandlerTextFieldTypeSmartCatalogFieldType wrapping the smart-catalog validatorFR-2801
filterShape()DEFAULTDEFAULT (JSONB; filters operate on embedded source_record_slug/label, not free text)FR-2802
searchKind()TEXT (tsvector)NONE — a catalog selection is not full-text searchedFR-2803
isPII()falsefalse (a product selection is not a data subject's PII) — sameFR-2804
needsTimezone()falsefalsesame
Auto-indexnonenone (not in AUTO_INDEX_FIELD_TYPES); [NEW] opt-in GIN on the JSONBFR-2805
What the value isa raw scalar stringa snapshot of picked SOURCE records (slugs + qty + unit_price)FR-2810, FR-2830
Config (options)min/max_length, patternsource_object_slug, selection_mode, display_fields?, line_items_mode?, price_field?, quantity_default?, price_editable?, decimal_precision?FR-2820..FR-2827
Incoming valuestored as-typedvalidated against the SOURCE object's records (slug must exist + be permitted)FR-2831, FR-2832
Stored shaperaw stringsingle {source_object_slug, source_record_slug, quantity?, unit_price?}, or multi array, or line-items {rows:[…], vat_percent}FR-2810
Reference formn/aslug, never numeric id (source_record_slug)FR-2811
Read-time shaperaw stringenriched: adds resolved_label, line_total, source_removedFR-2840
Commerce mathnoneline_total = qty × unit_price; subtotal = Σ; VAT = subtotal × vat_percent; grand totalFR-2850..FR-2853
Price behaviourn/aunit_price is a SNAPSHOT at selection time (invoice immutability); live price only on re-pickFR-2851, headline
Source deletedn/avalue/line survives; enrichment flags source_removed=true, label → "<name> (removed)"FR-2841, headline
Browse (picker)n/abrowse()browseSourceRecords filters/searches/paginates SOURCE records permission-awareFR-2860, headline SEC
Primary CRM rolefree stringthe invoice/quote line-item builder on top of the dynamic-object systemFR-2870, §14.4

Delta-from-relation note (the contrast that frames this document). relation (FRD-27) is a structural foreign key: it links two records through a relation table, is bidirectional, and holds no data of its own beyond the link — editing the target record is the source of truth, and there is no "snapshot". smart_catalog is a value-embedded picker with commerce semantics: the selection (which source record, how many, at what unit price) is copied into the JSONB value at selection time and lives on the referencing record. So:

  • A relation always reflects the current state of the linked record; a smart_catalog line reflects the state as of selection (the price is frozen — FR-2851 — because an invoice must not change when the catalog price changes tomorrow).
  • Deleting the target of a relation removes the link; deleting the source record of a smart_catalog line keeps the line (a paid invoice must survive its product being discontinued) and only marks it source_removed (FR-2841).
  • A relation has no quantity/price/VAT; smart_catalog is built for line-item math (FR-2850..FR-2853).

Everything not in this table or the note is identical to text — same FieldEntity (type='smart_catalog'), same @Unique(['name','object']), same slug @BeforeInsert, same sanitizePgIdentifier on column_name, same one-transaction metadata+DDL create (repoProvider.transaction), same SchemaGenerator.addColumn, same FieldTypeRegistrySmartCatalogFieldType, same shared serializer dispatch, same record read/write path, same A/G/M/D + route-gate enforcement, same RLS + workspace_id pinning on hyper, same audit subscriber, same @TenantScoped() Bull workers.

One-line thesis: smart_catalog = text where the value is a snapshot of picked records from another (SOURCE) object, optionally as line items with frozen unit prices and VAT, browsed through a permission-aware picker, enriched on read with label / line_total / source_removed, and rolled up by the CALCULATION engine's SUM_LINES into an invoice/quote total.


1. Overview & Goal

Purpose. smart_catalog is the field that turns a plain CRM record into an invoice, quote, or order. It lets a user pick one or more records from a configured SOURCE object — a "Products" object, a "Services" object, a "Price List" — and, when line-items mode is on, capture quantity and a unit price per pick, compute per-line and rolled-up totals, and apply VAT. The picked selection is stored as a JSONB snapshot on the referencing record; on read it is enriched with a human resolved_label, a computed line_total, and a source_removed flag. It is the commerce backbone of the dynamic-object system: the same machinery that lets an admin invent a "Deal" object also lets them attach a real, math-correct line-item table to it without writing any code.

Business "why". A GCC-first CRM is a selling tool, and selling means quotes and invoices. Those are not free text and not a single money field — they are a table: rows of "which product, how many, at what price", a subtotal, VAT, and a grand total, rendered as a PDF the customer receives. smart_catalog gives the platform that table as a field, reusing the object/field engine (the catalog is just another object, so it gets its own fields, permissions, import, and audit for free) instead of a bolted-on invoicing module. Two properties make it trustworthy money: (1) the price is a snapshot — an old quote shows the price the customer was actually quoted, even after the catalog changes (FR-2851); and (2) a discontinued product does not break a historical invoice — the line survives and is flagged source_removed (FR-2841). Unlike relation (a live structural link), smart_catalog is a priced snapshot built for commerce.

Measurable success.

  • Create-field p95 < 500 ms (metadata + ADD COLUMN … JSONB in one tx; config validated incl. price_field is numeric on the source — FR-2825).
  • browse() over a 10⁵-record source object returns a filtered/searched page in < 200 ms, and returns only records the caller is permitted to see (permission-aware — FR-2860, headline SEC-2801).
  • 100% of line totals are round(quantity × unit_price, decimal_precision); subtotal = Σ line_total; VAT and grand total are deterministic and match the rendered PDF to the minor unit (no 3-dp KWD truncation — FR-2853, inherits FRD-22).
  • A source record deleted after selection never breaks a stored value or a historical total: the line survives, source_removed=true, label renders "<name> (removed)" (FR-2841) — measured by: 0 read crashes and 0 silently-dropped lines after a source delete.
  • 0 raw slugs and 0 cross-workspace/unpermitted source records ever surface through the picker or a rendered invoice (enrichment + permission-aware browse + RLS — FR-2840, FR-2860, SEC-2801/2803).

Rides on core. Reuses FieldEntity (type='smart_catalog'), TypeMapperService (→ JSONB), SchemaGenerator.addColumn, SmartCatalogFieldType handler + the smart-catalog validator, the shared serializer, the record read/write path, and — smart-catalog-specific — the services under fields/services/smart-catalog/: smart-catalog.service.ts (thin browse() facade), smart-catalog-query.service.ts (browseSourceRecords: permission-aware filter/search/paginate of SOURCE records), smart-catalog-filter.service.ts (exact-identity filtering), and smart-catalog-line-items.service.ts (validatePriceFieldConfig — price field MUST be numeric on the source). Controller controllers/smart-catalog.controller.ts; DTO dto/smart-catalog/browse-catalog.dto.ts; interfaces shared/interfaces/smart-catalog-options.interface.ts, smart-catalog-value.interface.ts. No new object, no new engine, no new field type introduced — the catalog itself is an ordinary object.


2. Actors & Roles

ActorTypePermissions requiredEntry trigger
Workspace admin (field author)humanAdminAuthGuard + PermissionGuard, @RouteName('field.create'/'field.update'/'field.delete')Object → "Add field" → picks "Smart Catalog"; configures the SOURCE object + line-items
Record editor (quote builder)humanobject level ≠ D for create/update on the host object; route gate create.{slug} / update.{slug}; AND read level ≠ D on the SOURCE object (to browse it)Opens a record → picks catalog items → sets qty/price
Record viewerhumanhost object viewD; route gate read.{slug}Opens record/list/PDF — sees resolved labels + line totals
Picker / browse userhumanread on the SOURCE object (A/G/M/D enforced by browseSourceRecords)Types in the item picker → browses/searches source records
Import processorsystem@TenantScoped() Bull jobBulk import maps columns → line rows (slug refs + qty + price)
CALCULATION engine (SUM_LINES)systeminternalRolls line-items up: Σ(qty×price), subtotal, VAT, grand total (FRD-30)
Document/PDF renderersysteminternal (Puppeteer)Renders the line-items table (rows, qty, unit price, line total, subtotal, VAT, total) — the classic invoice/quote PDF
Dashboard/Analytics enginesysteminternalRevenue by product / by catalog item (group-by the referenced source record)
Workflow / Synapse enginesysteminternalcatalog-changed trigger; generate-records from lines (one child record per line)
Serial-number engine (FRD-29)systeminternalAn invoice carrying a smart_catalog also carries a serial (invoice number)
AI assistant (v1/v2)systemtool + confirmation gateReads/writes a quote's lines; validated same path; source_type='ai'
Public form intakeexternal → systempublic controller + reCAPTCHA(Rare) public order form picks from a public catalog

3. Pre-conditions & Assumptions

  • PRE-1. Parent (host) object exists with its dedicated physical table (SchemaGenerator.createObjectTable).
  • PRE-2. The SOURCE object named by options.source_object_slug exists in the same workspace and has its own table + fields (it is an ordinary object — a catalog). A smart_catalog with a dangling source is rejected at create (FR-2821).
  • PRE-3. Field-CRUD actor passed AdminAuthGuard + PermissionGuard.
  • PRE-4. On hyper-tenant, request is workspace-pinned (WorkspacePinningInterceptor set app.workspace_id); on dedicated tenant, isHyperTenant=false and pinning is skipped. Both the host and the source object live in the same workspace — a cross-workspace source is impossible under RLS (SEC-2803).
  • PRE-5. When line_items_mode=true, options.price_field names a field on the SOURCE object that is numeric (number or currency's parsed amount) — enforced by validatePriceFieldConfig at create/update (FR-2825). A non-numeric price field is rejected.
  • ASSUMPTION A-1. The stored value is a value-embedded snapshot, not a live link — unit_price captured at selection time is frozen (invoice immutability). A later catalog price change does not rewrite stored lines; the live price is read only when the user re-picks the item. Assumption pinned FR-2851; headline decision (recommend frozen).
  • ASSUMPTION A-2. References are by source_record_slug (stable, human-readable, environment-portable), never numeric id (slug-first convention). Assumption pinned FR-2811.
  • ASSUMPTION A-3. A deleted source record does not delete or corrupt the line — the line survives and is read-flagged source_removed=true; its resolved_label renders "<name> (removed)". Assumption pinned FR-2841; headline reliability decision.
  • ASSUMPTION A-4. In line-items mode all lines share one currency — mixed-currency lines are meaningless and must never be blind-summed (inherits FRD-22 SEC-2210). The catalog is assumed single-currency (or the field/host carries the currency). Assumption pinned FR-2853; headline correctness decision.
  • ASSUMPTION A-5. quantity defaults to options.quantity_default (or 1) and is a positive number; unit_price defaults to the source record's price_field value at selection. Negative/zero/huge quantities and negative unit prices are validation concerns (FR-2833, SEC-2804/2805). Assumption pinned.
  • ASSUMPTION A-6. price_editable governs whether the user may override the snapshot unit_price; when false the stored unit_price is always the source's catalog price at selection (FR-2827). Overrides are a fraud surface and MUST be audited (SEC-2805). Assumption pinned.
  • ASSUMPTION A-7. Field name uniqueness is per-object (@Unique(['name','object'])); slug global-unique.
  • ASSUMPTION A-8. Readers tolerate a legacy bare-array value shape via normalizeLineItemsValue — an old value stored as a plain array of rows is normalized to the canonical {rows:[…], vat_percent} on read. Assumption pinned FR-2812.

4. Post-conditions

OutcomeSystem state
Field created (success)New fields row (type='smart_catalog', column_type='jsonb', options with a valid source_object_slug + selection_mode (+ line-items config), workspace_id on hyper); new JSONB column; committed in ONE tx; audit entry.
Field create fails (config)No row, no column. Tx rolled back. 4xx (missing source_object_slug/selection_mode, dangling source, non-numeric price_field).
Field create fails (DDL)No orphan column (metadata rolled back with DDL). 5xx logged.
Browse (picker) successbrowseSourceRecords returns a permission-filtered (A/G/M/D on SOURCE), searched, paginated page of source records (display_fields projected) — the caller sees only source records they may read (FR-2860).
Record write — valid selectionIncoming picks validated against the SOURCE (each source_record_slug exists + permitted; price numeric; qty/vat sane) → stored as the canonical JSONB shape (single / multi / line-items); audit before/after; totals recomputable by SUM_LINES.
Record write — unknown/forbidden slug422 INVALID_CATALOG_ITEM {slug} — a slug not in the SOURCE object, or one the caller may not read, is rejected; nothing written (FR-2832, SEC-2801/2803).
Record write — empty / null / []Stored as NULL (empty ≡ absent — FR-2813).
Read (enriched)Each embedded pick/line resolved: adds resolved_label, line_total (line-items), and source_removed (if the source record is gone). Response DTO returns the enriched shape, never bare slugs.
Read (source removed)The line survives; source_removed=true, resolved_label="<name> (removed)" (the trashed record is resolved for its name; bare "(removed)" only when the name is unrecoverable); line_total still computes from the stored qty×unit_price (frozen), so the invoice total is preserved (FR-2841).
Source catalog price changed laterStored unit_price unchanged (snapshot, FR-2851); enrichment may surface a [NEW] price_stale hint comparing stored vs live; re-picking the item refreshes the price.
Field deletedfields row removed (unless is_system); JSONB column dropped; dependent calc (e.g. a SUM_LINES total field) flagged.
Field deactivated (is_active=false)Column retained; hidden from forms; values untouched.

5. Use Case Specification (Main / Happy Flow)

5A. Create a smart_catalog field

  1. Admin opens the host object → Add field → selects Smart Catalog.
  2. Form: Label (required) + Source object (required — pick an existing object, e.g. "Products") + Selection mode (single / multi) + Display fields (which source fields show in the picker + form the label). Optionally toggle Line items → then choose Price field (must be numeric on the source), Quantity default, Price editable?, Decimal precision.
  3. Submit → POST /object/field/:objectSlug (CreateFieldDto, type='smart_catalog', options).
  4. System validates DTO + validateFieldTypeOptions('smart_catalog', options) (requires source_object_slug + selection_mode); resolves the source object (must exist in workspace); if line-items → validatePriceFieldConfig (price field numeric on source) → column_type='jsonb'repoProvider.transaction: save FieldEntity + SchemaGenerator.addColumn.
  5. Returns the created field.
StepInput (typed)ValidationOutputFR-id
3name:string(1-100), label:string(1-100), type:'smart_catalog', required?:boolean=false, options:{ source_object_slug:string, selection_mode:'single'|'multi', display_fields?:string[], line_items_mode?:boolean, price_field?:string, quantity_default?:number, price_editable?:boolean, decimal_precision?:int }, is_active?:boolean=true@IsEnum(FieldType); name/label 1–100; source_object_slug + selection_mode required; source exists; price_field numeric if line-items201 + FieldResponseDtoFR-2800..FR-2827
4derived column_name, column_type='jsonb'sanitizePgIdentifier; fallback slug → field_<hex>JSONB column added; row savedFR-2807, FR-2820

Business rules (create):

  • FR-2800. type='smart_catalog'JSONB via TypeMapperService.getColumnType — MUST NOT change.
  • FR-2801. Handler is SmartCatalogFieldType (registered via the field-type registry). Boot-time assertFullCoverage fails fast if missing. filterShape()=DEFAULT, searchKind()=NONE, isPII()=false, needsTimezone()=false.
  • FR-2802. name unique per object (@Unique(['name','object'])); duplicate → 409 (as FRD-01 FR-101).
  • FR-2803. slug global-unique, auto-generated @BeforeInsert; never client-supplied (as FRD-01 FR-102).
  • FR-2807. column_name sanitized via sanitizePgIdentifier; fallback transliterated slug → field_<hex> (as FRD-01 FR-103).
  • FR-2820. options shape validated by validateFieldTypeOptions('smart_catalog', options): source_object_slug and selection_mode are REQUIRED; selection_mode ∈ {single, multi}; unknown keys ignored (forward-compat).
  • FR-2821. Source object must exist in the same workspace. source_object_slug is resolved against the workspace's objects; a dangling/foreign source → 422 INVALID_SOURCE_OBJECT. On hyper this resolution runs through the pinned connection, so a source in another workspace is invisible (RLS) and rejected (SEC-2803).
  • FR-2822. display_fields (if set) must name real fields on the SOURCE object; unknown field → 422. If unset, the source's primary/name field is used for the label.
  • FR-2823. line_items_mode (default false). When true, price_field is required.
  • FR-2824. quantity_default (if set) ≥ 0 (typically 1); decimal_precision (if set) an int in [0..6]; price_editable a boolean (default false).
  • FR-2825. validatePriceFieldConfig — when line_items_mode=true, options.price_field MUST name a numeric field on the SOURCE object (number, or currency whose amount is parseable). A non-numeric price field → 422 INVALID_PRICE_FIELD. (Headline config guard — a text "price" makes line math impossible.)
  • FR-2826. smart_catalog is not auto-indexed (AUTO_INDEX_FIELD_TYPES = select/status/priority/email/phone; smart_catalog is NOT in the list). [NEW] opt-in options.indexed=true creates a GIN index on the JSONB (useful for "which invoices reference product X" queries).
  • FR-2827. price_editable governs whether the record editor may override the snapshot unit_price. Default false (the catalog price is authoritative). When true, overrides are allowed but audited (SEC-2805).
  • FR-2828. Metadata + DDL commit in ONE repoProvider.transaction; partial failure rolls back both (no orphan column) — inherited from FRD-01 FR-106.
  • FR-2829. On hyper, workspace_id copied from parent object; save via pinned tx (RLS WITH CHECK passes).

5B. Browse the SOURCE catalog (the picker — headline flow)

  1. In the item picker the editor types a query / applies a filter.
  2. GET /object/field/:slug/smart-catalog/browse (browse-catalog.dto.ts) → SmartCatalogControllerSmartCatalogService.browse()SmartCatalogQueryService.browseSourceRecords.
  3. browseSourceRecords filters/searches/paginates the SOURCE object's records permission-aware — it applies the caller's A/G/M/D level on the SOURCE object via the same access-filter.builder used by the records engine (A no filter, G group, M own, D → nothing). It projects only display_fields.
  4. Returns a paginated page of {source_record_slug, <display_fields>, price?} for the picker to render.

Business rules (browse):

  • FR-2860. Permission-aware browse (headline SEC). browseSourceRecords MUST enforce the caller's read level on the SOURCE object. A user who cannot read the Products object (level D) gets an empty page; a user at level M sees only source records they own; G sees their group's. The picker must never surface a source record the caller could not otherwise read (no data leak via the picker — SEC-2801). Browse runs through repoProvider on the pinned connection (RLS on hyper).
  • FR-2861. Search over source records uses the source's own searchable fields (smart-catalog-query), sanitized (no tsquery/LIKE injection — inherits FRD-01 SC-REC-02/07). Pagination is bounded (max page size, cap total results) to prevent a full-catalog dump / OOM (SEC-2806).
  • FR-2862. smart-catalog-filter.service performs exact-identity filtering — resolving a specific source record by its exact identity (slug) — used both by browse ("is this exact item available?") and by write-time validation (FR-2832). Exact identity, not fuzzy match, so a write can never resolve to the wrong catalog item.

5C. Write a smart_catalog value on a record

  1. Editor submits { [field.slug]: value } where value is one of the three canonical shapes (single / multi / line-items) carrying source_record_slug(s) and, in line-items mode, quantity + unit_price + vat_percent.
  2. DynamicValidationServiceSmartCatalogFieldType.validate.
  3. Validator: (a) normalizes shape (normalizeLineItemsValue tolerates a legacy bare array); (b) for each pick, resolves source_record_slug against the SOURCE object via exact-identity filtering permission-aware (the slug must exist AND the caller must be permitted to read it); (c) validates qty/unit_price/vat_percent; (d) enforces selection_mode arity and price_editable.
  4. Shared serializer writes the canonical JSONB.
StepInputValidationOutputFR-id
1value: SmartCatalogSingleValue | SmartCatalogSingleValue[] | {rows:[…],vat_percent} | nullkeyed by field.slugFR-2830
2–3normalize shape; each source_record_slug exists + permitted; qty ≥ 0; unit_price ≥ 0 (unless override allowed); vat_percent in [0..100]; arity per selection_modecanonical JSONB, or nullFR-2831..FR-2836
4validatedserialize the canonical shapecolumn updated (JSONB or NULL)FR-2810..FR-2813

Business rules (write) — the headline deltas:

  • FR-2810. Stored shape. Three canonical forms (from smart-catalog-value.interface.ts):
    • single (selection_mode='single', no line-items) → SmartCatalogSingleValue &#123; source_object_slug, source_record_slug, quantity?, unit_price? &#125;.
    • multi (selection_mode='multi', no line-items) → an array of SmartCatalogSingleValue.
    • line-items (line_items_mode=true) → { rows: SmartCatalogSingleValue[], vat_percent }.
  • FR-2811. References are source_record_slug (string), never numeric id — slug-first (SEC-2802 IDOR). The value also carries source_object_slug for self-description (so a reader knows which object to enrich from).
  • FR-2812. Legacy bare-array tolerance. normalizeLineItemsValue accepts an older value stored as a plain array of rows and normalizes it to {rows:[…], vat_percent} (default vat_percent=0) on read/write — no data migration required; readers never crash on the legacy shape.
  • FR-2813. Empty / null / [] / {rows:[]} → stored as NULL (empty ≡ absent). IS_EMPTY matches NULL.
  • FR-2830. Payload keyed by field slug (never numeric id or column_name).
  • FR-2831. Each pick's source_record_slug MUST exist in the SOURCE object (exact-identity resolution, FR-2862). A slug absent from the source → 422 INVALID_CATALOG_ITEM {slug}, nothing written.
  • FR-2832. Permission-aware write validation. Resolution of a source_record_slug at write time runs through the same permission-aware path as browse — a caller cannot embed a source record they are not permitted to read (level D/out of M/G scope) even by supplying a valid slug directly (defeats browse-bypass, SEC-2801). Forbidden slug → 422 INVALID_CATALOG_ITEM (indistinguishable from "not found" so existence isn't leaked).
  • FR-2833. Quantity/price/VAT sanity. quantity ≥ 0 (default quantity_default or 1); reject negative and reject absurd magnitudes ([NEW] cap, e.g. ≤ 1e9 — SEC-2804). unit_price ≥ 0. vat_percent in [0..100] (reject out-of-range — SEC-2807). Values fail closed with INVALID_LINE_ITEM {slug, field, code}.
  • FR-2834. selection_mode arity. single → exactly one pick (an array of length > 1 → 422 TOO_MANY_ITEMS); multi/line-items → many allowed (bounded by a max-lines cap — SEC-2806).
  • FR-2835. price_editable enforcement. When price_editable=false, an incoming unit_price that differs from the source record's current price_field is ignored (the snapshot price is taken from the source, not the client) — the client cannot override. When true, the supplied unit_price is accepted and the override is audited (before/after vs catalog price — SEC-2805), and a unit_price < 0 is always rejected (FR-2833).
  • FR-2836. Price snapshot at selection (headline). At selection time the validator captures unit_price = (override if allowed, else the source's price_field value now). This snapshot is frozen in the stored value (FR-2851). A later catalog price change does not touch stored lines.
  • FR-2837. Required-gate + conditional-requirement identical to FRD-01 (FR-135/136): required + no pick → REQUIRED_FIELD; update skips untouched fields.

5D. Read / enrich

  • FR-2840. Enrichment. On read, each embedded pick/line is enriched by resolving source_record_slug against the SOURCE object: add resolved_label (from display_fields), and in line-items mode line_total = round(quantity × unit_price, decimal_precision). The response DTO returns the enriched shape; bare slugs are never emitted to a human surface (SEC-2803-leak).
  • FR-2841. source_removed (headline reliability). If a referenced source record no longer exists (deleted/soft-deleted), enrichment sets source_removed=true and resolved_label="<name> (removed)" for that line — the trashed row is still resolved for its name, so the reader sees what was bought, not a nameless placeholder; the marker degrades to a bare "(removed)" only when the name is unrecoverable (record purged, or the whole source object dropped, E12). The name is read live, never snapshotted, so it stays correct after a rename. The line and its stored qty/unit_price surviveline_total still computes from the frozen snapshot, so subtotal/VAT/grand total are unchanged and the historical invoice remains correct and printable. Never a read crash, never a silently dropped line, never a raw slug.
  • FR-2842. Enrichment resolves all referenced source records for a value in a single batched query per read (by slug set), not one query per line (avoid N+1 over source records — SEC-2806, reliability). For a list page, source records are batch-resolved across all rows.
  • FR-2843. filterShape()=DEFAULT on the JSONB — supported filters operate on the embedded source_record_slug (e.g. "records that reference product X" via JSONB containment) and IS_EMPTY/IS_NOT_EMPTY (NULL). It is not free-text searched (searchKind()=NONE, FR-2803).

5E. Line-item math (commerce semantics — inherits FRD-22)

  • FR-2850. line_total = round(quantity × unit_price, decimal_precision). Per-line, computed at read time from the stored qty and snapshot unit_price (not from the live catalog price).
  • FR-2851. Price is a frozen snapshot (headline decision). The stored unit_price is captured at selection time (FR-2836) and does not change when the source catalog price changes later — invoice immutability. The only way the price refreshes is re-picking the item (which re-snapshots the current price). Recommendation: frozen for financial integrity; a [NEW] price_stale read-hint may surface "catalog price changed from X to Y" without mutating the stored value. (Editing-later semantics: editing other fields of the record does not re-read the price; only an explicit re-pick does.)
  • FR-2852. Subtotal = Σ line_total across rows. VAT = round(subtotal × vat_percent/100, dp). Grand total = subtotal + VAT. All at read/aggregate time; nothing but the per-line qty/unit_price/vat_percent is stored (totals are derived — no stale stored total, inherits FRD-22 concurrency note).
  • FR-2853. Currency semantics (inherits FRD-22). All lines in one line-items value are assumed one currency — mixed-currency lines are meaningless and MUST NOT be blind-summed (SEC-2808, FRD-22 SEC-2210). Rounding uses the currency's per-currency decimal places (KWD/BHD/OMR = 3, JPY = 0 — never a hard-coded 2 — FRD-22 SEC-2203). decimal_precision in options may override but must not truncate a 3-dp currency below its minor unit.

5F. SUM_LINES rollup (CALCULATION — headline interaction, FRD-30)

  • FR-2854. A CALCULATION field can aggregate a smart_catalog line-items field via SUM_LINES, which computes Σ(qty × unit_price) (and, per config, applies VAT to yield the grand total). The result is a currency/number calc field on the same host record (e.g. a "Total" field on the invoice). SUM_LINES reads the frozen snapshot amounts (FR-2851), so a total equals what the customer was quoted. When a line is source_removed, its stored amount still contributes (the total does not drop when a product is discontinued — FR-2841).

6. Alternative & Exception Flows

FlowTriggerStepsResultmaps-to
A1Duplicate field name in objectcreate → DB unique violation409 FIELD_NAME_EXISTS, rollbackFR-2802
A2Missing source_object_slug or selection_modeoptions validation422 INVALID_OPTIONS (both required)FR-2820
A3source_object_slug doesn't exist / other workspacecreate422 INVALID_SOURCE_OBJECT (RLS-invisible on hyper)FR-2821, SEC-2803
A4line_items_mode=true but price_field non-numericcreate → validatePriceFieldConfig422 INVALID_PRICE_FIELD, no columnFR-2825
A5display_fields names an unknown source fieldcreate422 INVALID_OPTIONSFR-2822
E1Write a source_record_slug not in the sourcerecord write422 INVALID_CATALOG_ITEM {slug}, no writeFR-2831
E2Write a source slug the caller may not read (level D/out of scope)record write422 INVALID_CATALOG_ITEM (existence not leaked)FR-2832, SEC-2801
E3Write a slug from another workspacerecord write422 (RLS: not resolvable on the pinned conn)SEC-2803
E4selection_mode='single' but array of >1 pick submittedrecord write422 TOO_MANY_ITEMS {slug}FR-2834
E5Negative / huge quantityrecord write422 INVALID_LINE_ITEM (qty ≥ 0, ≤ cap)FR-2833, SEC-2804
E6unit_price override while price_editable=falserecord writeoverride ignored; snapshot = catalog priceFR-2835
E7Negative unit_price (override)record write422 INVALID_LINE_ITEM (always rejected)FR-2833, FR-2835
E8vat_percent = 250 (out of range)record write422 INVALID_LINE_ITEM (0..100)FR-2833, SEC-2807
E9Empty / [] / {rows:[]}record writestored NULL (empty ≡ absent)FR-2813
E10Legacy bare-array value on readread/enrichnormalizeLineItemsValue{rows:[…], vat_percent:0}; no crashFR-2812
E11Referenced source record deleted after selectionread/enrichline survives; source_removed=true, label "<name> (removed)"; total preservedFR-2841
E12Source object deleted entirelyread/enrichall lines source_removed=true, label falls back to bare "(removed)" (table dropped ⇒ no name to recover); value preserved; field flagged for admin repairFR-2841, SEC-U3
E13Source price_field later changed to a non-numeric typesource field editblocked while a smart_catalog references it as price_field, or field flagged inactive; line math falls back to stored snapshotFR-2825, SEC-U6
E14Permission denied on the host object (level D)create/update record403 route gate before body runsSEC-2801-acl
E15DDL ADD COLUMN … jsonb failstx aborts5xx, metadata+DDL rolled back, no orphanFR-2828
E16Delete a is_system smart_catalog fielddelete403 SYSTEM_FIELD_IMMUTABLE, no dropFR-2880
E17Mixed-currency linesread/aggregateblocked/flagged as meaningless; never blind-summedFR-2853, SEC-2808
E18Huge multi-select (10⁴ lines)record writecapped TOO_MANY_ITEMS / bounded; enrichment batchedFR-2834, SEC-2806
C1User cancels add-field dialogcloseno request, no state changeUX-2802

7. User Flow Diagram (ASCII)

            ┌──────────────────────────┐
            │ Admin: object → Add field│
            └────────────┬─────────────┘
                         │ UX-2801
                 ┌───────▼──────────┐
                 │ Pick "Smart      │
                 │  Catalog"        │
                 └───────┬──────────┘

          ┌──────────────▼───────────────────────────┐
          │ Label + Source object (req.)             │ UX-2803 config
          │ + selection_mode (single/multi)          │ server resolves source
          │ + [Line items] → price_field(numeric),   │ validatePriceFieldConfig
          │   quantity_default, price_editable, dp    │
          └──────────────┬───────────────────────────┘
                         │ submit FR-2800..2829
                 ┌───────▼─────────────────────┐
                 │ options valid?              │──no──► 422 (A2/A3/A4/A5)  [end: fix]
                 │ source exists? price numeric?│
                 └───────┬─────────────────────┘
                        yes
                 ┌───────▼─────────────────────────────┐
                 │ TX: save FieldEntity + ADD COLUMN    │ FR-2828
                 │      <col> JSONB                      │
                 └───────┬─────────────────────────────┘
                    ┌────┴────┐
                 fail│         │ok
             ┌───────▼──┐   ┌──▼───────────────┐
             │ rollback │   │ 201 field created│ [end: success UX-2806]
             │ 5xx (E15)│   └──────────────────┘
             └──────────┘

   ── Browse (picker) ──  GET …/smart-catalog/browse
     └► SmartCatalogService.browse → browseSourceRecords
          ├ apply caller A/G/M/D on SOURCE object  FR-2860 (D→empty, M→own, G→group)
          ├ search (sanitized) + paginate (bounded) FR-2861
          └► page of {source_record_slug, display_fields, price}  (only permitted rows)

   ── Record write path ──  {slug: value}  (single | multi | {rows,vat_percent})
     └► SmartCatalogFieldType.validate
          ├ normalizeLineItemsValue (legacy bare array → {rows,vat_percent})   FR-2812
          ├ null / [] / {rows:[]} ─────────────────────► NULL                  FR-2813 / E9
          ├ single mode & >1 pick ─────────────────────► 422 TOO_MANY_ITEMS    FR-2834 / E4
          ├ for each pick:
          │    ├ resolve source_record_slug (exact identity, permission-aware) FR-2831/2832/2862
          │    │     ├ not found / forbidden ──────────► 422 INVALID_CATALOG_ITEM  E1/E2/E3
          │    │     └ ok
          │    ├ qty ≥0 & ≤cap ; unit_price ≥0 ; vat 0..100 ─no─► 422 INVALID_LINE_ITEM  E5/E7/E8
          │    └ price snapshot: editable? override(audited) : catalog price   FR-2835/2836

              FieldProcessor gate (required/default)   FR-2837

              serialize → canonical JSONB   FR-2810   → audit before/after

   ── Read path ──► enrich each line:
          resolved_label (display_fields)  +  line_total = round(qty×unit_price, dp)  FR-2840/2850
          source record gone? → source_removed=true, label "<name> (removed)", line survives  FR-2841 / E11

          subtotal = Σ line_total ; VAT = subtotal×vat% ; grand total   FR-2852
          SUM_LINES calc rolls up (frozen snapshot amounts)  FR-2854

          Document Template renders the INVOICE line-items TABLE (labels, not slugs)

7b. UX / UI / Simplicity Spec

Step-count budget (create a smart_catalog field): 4 touches for the simple picker, ~6 for a full line-item invoice field (source + line-items config). More than text because a catalog picker inherently needs a source object.

#Screen / clickJustified?Removable?
1Click "Add field"yes — entryno
2Pick "Smart Catalog" tileyes — type choiceno
3Type Labelyes — requiredno
4Pick Source object + selection modeyes — a catalog picker needs a catalog (PRE-2)no
5 (opt)Toggle "Line items" → price field + defaultsonly for invoices/quotesyes — off = plain picker
6 (opt)display_fields / price_editable / dpadvanced, collapsedyes
  • UX-2801. "Smart Catalog" tile in the "advanced/commerce" group of the type picker (a receipt/basket glyph), with a helper line distinguishing it from Relation ("Relation links to a record and stays live; Smart Catalog snapshots a priced selection for a quote/invoice").
  • UX-2802. Cancel/close = zero state change; no confirm.
  • UX-2803 (config). Source object is a searchable dropdown of the workspace's objects. display_fields is a multi-select of the source's fields (defaults to its name field). Line-items toggle reveals price_field (numeric-only dropdown — a text field is not offered, FR-2825), quantity_default, price_editable, decimal_precision.
  • UX-2804 (the item picker). At record edit, a searchable catalog picker shows source records as rows of the chosen display_fields (+ price). It shows only records the user may read (permission-aware, FR-2860) — a confidential product line is simply not in the list. Single mode = radio (replaces); multi/line-items = add rows.
  • UX-2805 (line-item editor). A table: each row = item label, qty (numeric), unit price (numeric — editable only if price_editable, otherwise read-only from the catalog), line total (computed, read-only). Below: a VAT % input, a subtotal, VAT amount, and grand total that update live as rows/qty change. Currency shown per the field/source currency; KWD shows 3 dp, JPY 0 (never a hard-coded 2).
  • UX-2806 (success). Toast "Field '<Label>' added"; the column renders as a line-items summary chip ("3 items · SAR 1,250.00") in list view and expands to the full table on the record.
  • UX-2807 (record error). Inline, exact messages: "That item is no longer in the catalog" (invalid/forbidden), "Quantity must be 0 or more", "VAT must be between 0 and 100", "USD uses 2 decimal places / KWD uses 3". Keeps typed rows (no data loss).
  • UX-2808 (source-removed state). A source_removed line renders its grey "<name> (removed)" label — the item's own name is part of resolved_label, so the client renders it as-is and must NOT append a second "(removed)" of its own — plus a tooltip "This catalog item was deleted; the price is preserved from when it was added." The line stays in the table and the total (never silently dropped) — the user may keep it (historical) or remove it explicitly.
  • UX-2809 (price-stale hint, [NEW]). If the live catalog price differs from the frozen snapshot, an inline badge "Catalog price changed (was X, now Y) — re-add to update" appears, without mutating the stored price.
  • UX-2810 (render rule). Every human surface (form, list chip, kanban, PDF, export, message) renders the resolved label (never the raw source_record_slug) and the computed totals (FR-2840, SEC-2803-leak).
  • UX-2811 (zero dead-end). An unresolvable pick offers "Search the catalog" instead of a dead 422; a removed item offers "Keep as-is" or "Replace with a current item".
  • Forgiving input. Qty accepts Arabic-Indic digits (normalized); price input reuses the money control (FRD-22). Clearing all rows = NULL, no error (if not required). Reorder rows by drag (display order only).
  • States copy. Empty: "No items yet — add from the catalog". Loading: picker disabled + spinner. Partial (import): "48 of 50 lines mapped · 2 unknown items reported".
  • Accessibility. The line-items table is a real <table> with header cells; totals announced via aria-live; picker keyboard-navigable; RTL-aware — Arabic invoices render the table RTL with amounts LTR; colour is never the only signal for a removed line (text label always present).

8. Data & State Model

Field definition (fields table): identical columns to FRD-01 §8, with:

ColumnValue for smart_catalog
type'smart_catalog'
column_type'jsonb'
options.source_object_slugrequired — slug of the SOURCE object (a catalog); must exist in workspace
options.selection_moderequired'single' | 'multi'
options.display_fieldsoptional string[] of source field slugs (picker columns + label)
options.line_items_modeoptional boolean (default false)
options.price_fieldrequired when line_items_modenumeric field slug on the source (validatePriceFieldConfig)
options.quantity_defaultoptional number ≥ 0 (default 1)
options.price_editableoptional boolean (default false)
options.decimal_precisionoptional int [0..6] (rounding for line math; per-currency dp still respected)
options.indexed[NEW] boolean, default false (GIN on JSONB)

Value column (host object's dedicated table): <column_name> JSONB NULL. Canonical stored shapes (smart-catalog-value.interface.ts):

single (selection_mode='single', no line-items):
  { "source_object_slug":"products", "source_record_slug":"widget_a1", "quantity":2, "unit_price":49.99 }

multi (selection_mode='multi', no line-items):
  [ { source_object_slug, source_record_slug, quantity?, unit_price? }, … ]

line-items (line_items_mode=true):
  { "rows":[ { source_object_slug, source_record_slug, quantity, unit_price }, … ], "vat_percent":15 }

legacy (tolerated on read via normalizeLineItemsValue):
  [ { source_record_slug, quantity, unit_price }, … ]   → normalized to { rows:[…], vat_percent:0 }

Read-time enrichment adds (never stored): resolved_label, line_total (line-items), source_removed, and [NEW] price_stale. Empty / null / [] / {rows:[]}NULL.

State machine: the value is a priced snapshot, not a lifecycle machine. Two "soft" state transitions of interest are read-derived, not stored:

EntityFromEventToGuard
fieldactivePUT /toggle-statusinactivefield exists, not deleted
fieldinactivePUT /toggle-statusactive
lineresolvablesource record deletedsource_removed (read-derived)value/qty/unit_price preserved (FR-2841)
linefrozen priceuser re-picks the itemre-snapshotted to live priceonly re-pick refreshes (FR-2851)

9. Business Rules Catalog

FRRuleConcrete value
FR-2800Column typeJSONB
FR-2801HandlerSmartCatalogFieldType (filter=DEFAULT, search=NONE, isPII=false)
FR-2820Required optionssource_object_slug + selection_mode
FR-2821Source existsresolved in-workspace; dangling/foreign → 422 (RLS-invisible on hyper)
FR-2825Price field numericvalidatePriceFieldConfig — line-items price_field MUST be numeric on source
FR-2826Auto-indexnone; [NEW] options.indexed → GIN
FR-2827price_editabledefault false; overrides audited
FR-2810Stored shapesingle / multi array / {rows,vat_percent}
FR-2811Reference formsource_record_slug (never numeric id)
FR-2812Legacy tolerancenormalizeLineItemsValue (bare array → canonical)
FR-2813Emptynull / [] / {rows:[]} → NULL
FR-2831Slug must existunknown source slug → 422 INVALID_CATALOG_ITEM
FR-2832Permission-aware writeforbidden source slug → 422 (existence not leaked)
FR-2833Line sanityqty ≥0 (≤cap); unit_price ≥0; vat 0..100
FR-2834Aritysingle → 1 (else TOO_MANY_ITEMS); multi/line-items → many (capped)
FR-2835Override controlprice_editable=false ⇒ client price ignored; true ⇒ accepted + audited
FR-2840Enrichmentadd resolved_label, line_total, source_removed on read
FR-2841source_removedline survives; label "<name> (removed)"; frozen total preserved
FR-2850Line totalround(qty × unit_price, dp)
FR-2851Price snapshotfrozen at selection; refresh only on re-pick
FR-2852Rollupsubtotal = Σ; VAT = subtotal×vat%; grand = subtotal+VAT
FR-2853Currencyone currency per value; per-currency dp; no mixed sum
FR-2854SUM_LINESCALCULATION rolls up frozen amounts (incl. source_removed lines)
FR-2860Permission-aware browsebrowseSourceRecords enforces caller A/G/M/D on SOURCE
FR-2862Exact identitysmart-catalog-filter resolves an exact source record by slug

10. Integrations & Dependencies

IntegrationRequired/OptFailure mode if unavailableFallback
TypeMapperService (→ JSONB)required— (in-proc)n/a
SchemaGenerator/SchemaBuilder (DDL)requiredcreate fails → tx rollback5xx, no orphan
FieldTypeRegistry + SmartCatalogFieldTyperequiredboot assertFullCoverage throws if missingfail-fast at startup
SOURCE object + its records/fieldsrequiredif source missing/deleted → picks enrich to source_removedvalue survives, admin repairs (FR-2841)
SmartCatalogQueryService.browseSourceRecords (permission-aware)required-for-pickerpicker can't list → editor can't add itemserror surfaced; existing value intact
access-filter.builder (A/G/M/D on source)requiredwithout it the picker would leak forbidden rowsfail closed — deny path (SEC-2801)
validatePriceFieldConfigrequired (line-items)mis-config → create rejected422 at create (FR-2825)
Read-time enrichment (label/line_total/source_removed)required-for-displayraw slugs would leak (SEC-2803-leak)render "<name> (removed)"/label; never slug
CALCULATION engine (SUM_LINES)optionalinvoice total field can't computetotals recompute when engine up; stored lines intact (FRD-30)
Document Templates + Puppeteer PDFoptionalinvoice/quote PDF (line-items table) can't rendergraceful; labels+totals when up
Dashboards / Analyticsoptionalrevenue-by-product widget emptyfield usable; widget deferred
Workflow / Synapseoptionalcatalog-changed trigger; generate-records-from-linesBull retry/backoff
Serial-number engine (FRD-29)optionalinvoice number not assignedserial issued when engine up
Export/Import (Bull, ExcelJS)optionallines as rows; slug refs; source resolutionjob retries (@TenantScoped)
Deduplication / mergeoptionalmerging invoices/quotes with catalog linesmerge rule keeps/combines lines
Audit subscriberrequiredvalue writes; price overrides audited (SEC-2805)eventual audit
Redis (cache)optionalfield-metadata / source-record cache miss → DB readslower, correct

11. Security Specification

smart_catalog's security themes (the reason it is the top-complexity type): (1) the picker must not leak source records the caller can't access (permission-aware browse and write validation); (2) no IDOR / cross-workspace source reference via a forged slug; (3) price/qty/VAT are money — overrides, negatives, and VAT tampering are fraud surfaces; (4) a snapshot vs live price divergence must be a deliberate, auditable behavior, not a silent bug; (5) a deleted source must never break a historical invoice; (6) JSONB integrity (no proto-pollution / mass assignment / mixed-currency nonsense).

SECThreatAttack vectorMitigation (by design)maps-to
SEC-2801-aclBroken access control (host)edit a quote without object rightsPermissionGuard route gate update.{slug} + A/G/M/D row filter on the HOST objectFR-2837, E14
SEC-2801Permission-aware browse bypass (headline)user browses/searches the picker to see a confidential product list they can't read, or forges a valid source slug in the write body to embed a record they can't seebrowseSourceRecords enforces the caller's read A/G/M/D on the SOURCE object (D→empty, M→own, G→group); write-time resolution runs the same permission-aware path so a forged-but-valid slug is rejected INVALID_CATALOG_ITEM (existence not leaked); both run on the pinned RLS connFR-2860, FR-2832, E2, T-2803/2804
SEC-2802-idorIDOR via forged source_record_slugpass a numeric id or a guessed slug to reach another recordAPI is slug-only; resolution is exact-identity against the SOURCE object's records filtered by the caller's level; a slug outside scope → 422 (not found ≡ forbidden)FR-2811, FR-2831/2832, T-2804
SEC-2803Cross-workspace source reference (hyper)embed a source_record_slug (or source_object_slug) from workspace B into workspace A's write; or resolve a source record via a non-RLS pathsource resolution goes through repoProvider on the pinned connection — a foreign object/record is invisible (RLS ws_isolation), so the slug doesn't resolve → 422; never resolve via getDataSource()/unpinned runner (guardrail spec)FR-2821/2829, T-2813
SEC-2803-leakRaw slug / forbidden data leak on renderan enrichment-skipping code path (new PDF template, CSV export, kanban, message) emits a bare source_record_slug or a source field the viewer can't seeenrichment centralized in the read path; every render/export/message resolves slugs → labels via display_fields only; the raw column value is never emitted to a human channel; contract-testedFR-2840, T-2808
SEC-2804Quantity abuse (negative / huge)quantity:-5 (credit fraud) or quantity:1e12 (overflow / DoS total)quantity ≥ 0 and ≤ cap (FR-2833); rejected INVALID_LINE_ITEM; totals computed in a safe numeric typeFR-2833, E5, T-2805
SEC-2805price_editable abuse → fraud (headline)a rep overrides unit_price to 0 or a negative to zero-out / invert an invoice; or overrides prices silently to under-billwhen price_editable=false the client price is ignored (snapshot = catalog price, FR-2835); when true, unit_price < 0 is always rejected and every override is audited (before=catalog, after=override, actor, source_type); [NEW] clamp/approval gate on large discountsFR-2827/2833/2835, E6/E7, T-2806
SEC-2807VAT tamperingvat_percent:0 to dodge tax, vat_percent:250 to inflate, or a non-numeric VATvat_percent validated to [0..100] numeric (FR-2833); out-of-range → 422; VAT recomputed at read from the stored percent (not a client-sent VAT amount)FR-2833, E8, T-2807
SEC-2806Enrichment N+1 / unbounded browse (DoS)a value with 10⁴ lines, or a browse that dumps the whole catalog → N+1 source queries, OOM, slow renderline count capped (FR-2834); browse paginated + bounded (FR-2861); enrichment batch-resolves all slugs in one query per read (FR-2842)FR-2834/2842/2861, E18, T-2818
SEC-2808Mixed-currency lines sum to nonsenselines in USD + KWD + JPY blind-summed into one "total"one currency per value assumed (FR-2853); a mixed-currency value is blocked/flagged, never blind-summed; SUM_LINES/dashboards group-by-code or convert explicitly (inherits FRD-22 SEC-2210)FR-2853, E17, T-2817
SEC-2809Deleted source breaks a historical totala discontinued product's line silently drops → the invoice total changes / the PDF crashessource_removed=true keeps the line + its frozen qty/unit_price; line_total/subtotal/VAT/total computed from the stored snapshot (FR-2841); enrichment never throws on a missing source; batch-resolve tolerates gapsFR-2841, E11/E12, T-2811
SEC-2810Stale snapshot vs live price (feature-or-bug)an old quote shows a price no longer in the catalog — is that correct (frozen) or a bug (stale)?deliberate: frozen for invoice immutability (FR-2851); a [NEW] price_stale read-hint surfaces the divergence without mutating stored data; re-pick refreshes; documented, not silentFR-2851, T-2810, UU-1
SEC-2811Mass assignmentPOST is_system/workspace_id/column_type, or extra JSONB keys (line_total, resolved_label, source_removed) hoping they persistDTO whitelist; server-set fields only; enriched keys are read-only — the serializer stores ONLY {source_object_slug, source_record_slug, quantity, unit_price, (vat_percent)}, dropping any client-sent line_total/resolved_label/source_removed (they are always recomputed)FR-2810/2840, T-2814
SEC-2812JSONB proto-pollution / crafted valuea value with __proto__/constructor keys or deeply-nested JSON to pollute prototypes or blow the parservalue validated to the canonical shape (allow-listed keys only) before store; JSON parsed safely (no merge-into-prototype); depth/size bounded; unknown keys dropped, not mergedFR-2810, T-2812
SEC-2813SQL/JSONB injection via field name or slugname="x; DROP…"; or a crafted source_record_slug string in a JSONB filtersanitizePgIdentifier on identifiers; slugs resolved to a closed set (source records) via parameterized TypeORM JSONB queries — no string interpolationFR-2807/2831
SEC-2814Audit bypasswrite the JSONB column outside the APIall writes go through the record service + TypeORM subscriber; DB-direct writes out-of-band by policy; price overrides specifically audited (SEC-2805)FR-2810, SEC-2805
SEC-2815Unsafe type migrationsmart_catalog → text flattens the JSONB to a raw blob (slug leak); smart_catalog → relation (structural, drops price/qty)smart_catalog → text MUST render a human summary (labels + totals) or be blocked (never dump raw slugs); → relation drops commerce data — blocked/cast-and-report; widening single→multi is safe (wrap in array)SC-ENC-05/06, T-2815
SEC-U1Unknown-unknown: snapshot silently diverges from the catalogcatalog price rises 20%; an old quote still shows the old price — a customer/auditor calls it "wrong"it is deliberate (FR-2851, invoice immutability); make it visible with a price_stale hint + an audit trail of when the snapshot was taken; never auto-rewrite stored pricesFR-2851, SEC-2810, T-2810
SEC-U2Unknown-unknown: price_editable lets a rep zero-out an invoice for fraudrep sets every unit_price to 0.01 or 0 to under-bill a colluding customeraudit every override (before/after vs catalog); [NEW] discount clamp + approval gate above a threshold; report on aggregate override deltas per repFR-2835, SEC-2805, T-2806
SEC-U3Unknown-unknown: the whole SOURCE object is deleted, not just a recordadmin deletes "Products" → every quote's every line danglesall lines source_removed=true (value preserved, totals frozen); block deleting a source object while a smart_catalog references it, or require an explicit "orphan all references" confirm; field flagged for repairFR-2841, E12, T-2816
SEC-U4Unknown-unknown: permission-aware browse leaks a confidential product list via searcha rep can't list Product X but searches its exact name/SKU and infers existence from a hit/no-hitsearch runs inside the same A/G/M/D filter (search never widens the visible set); a forbidden record is absent from results regardless of query; no count/existence oracleFR-2860/2861, T-2803
SEC-U5Unknown-unknown: mixed-currency total shown as a headline figurea quote mixes SAR and USD lines; the grand total reads "1,300" of nothingone-currency invariant (FR-2853); a mixed-currency value is flagged and its blind total is not rendered as a headline (group-by-code or convert-explicit)FR-2853, SEC-2808, T-2817
SEC-U6Unknown-unknown: source price_field type flipped to non-numeric mid-lifeadmin edits the Products "price" field from number to text → new line math breaksblock changing a field's type to non-numeric while it is a smart_catalog price_field (FR-2825/E13), or auto-flag the field inactive and fall the math back to the stored snapshot (existing lines keep working)FR-2825, E13, T-2825
SEC-U7Unknown-unknown: a forged source_record_slug from another workspace resolves via a non-RLS patha background job or a helper that reads source records with getDataSource() (unpinned) resolves a foreign slug, bypassing RLSALL source resolution MUST use repoProvider (pinned) — never getDataSource()/fresh createQueryRunner() without the GUC; enforced by the static RLS-pinning guardrail spec; foreign slug → not resolvable → 422FR-2829, SEC-2803, T-2813

12. Reliability & Uptime (100% target)

ComponentFailureDetectionFallbackRecoveryRTO
DDL (ADD COLUMN JSONB)ALTER fails mid-createtx errorrollback metadata+DDLretry createimmediate
DB downwrite rejectedconn error503, no partial writeclient retryon restore
Source record deletedreferenced slug goneread-time (batch resolve gap)source_removed=true, line + frozen total surviveadmin re-points/removes lineimmediate
Source object deletedsource_object_slug unresolvableread-timeall lines source_removed; value preservedblock source delete while referenced (SEC-U3) / repairimmediate
validatePriceFieldConfig failsnon-numeric price fieldcreate/edit timereject config; no field createdadmin picks a numeric fieldimmediate
Enrichment lookup (N sources)slow / N+1read timerbatch-resolve all slugs in one query (FR-2842)tune / [NEW] GIN indeximmediate
SUM_LINES engine downtotal field not recomputedcalc queuestored lines intact; total recomputes when upBull retry (FRD-30)seconds
PDF renderer downinvoice PDF not producedrender errordata intact; regenerate on demandretryseconds
Browse/query heavylarge source scanquery timerbounded pagination (FR-2861); [NEW] indextuneminutes
Import queue backlogline rows delayedqueue depthBull persists; @TenantScoped retryretry+backoffminutes
Audit subscriber slowoverride log lagqueue depthvalue committed; audit eventualeventual flushseconds

Integrity guarantees.

  • Transactional (strong): field create/delete (metadata + DDL in one repoProvider.transaction); record write of the JSONB column is atomic (single column, single row).
  • Eventual: audit change-log, SUM_LINES rollups, dashboard revenue-by-product, webhook/Synapse fan-out.
  • Snapshot invariant (strong): a stored unit_price is frozen at selection (FR-2851) — no later catalog change rewrites it; the only refresh path is an explicit re-pick. This is what makes a historical total stable.
  • Survival invariant (strong): a stored line is never dropped or corrupted by a source delete — it is read-flagged source_removed and its frozen amount still totals (FR-2841). A historical invoice remains correct and printable after any catalog change/delete.
  • Closed-set-at-write invariant (strong): every stored source_record_slug existed in the SOURCE object and was permitted to the writer at write time (FR-2831/2832). It can become source_removed only via a later delete — never invalid at write.
  • Totals-are-derived: subtotal/VAT/grand-total are recomputed from stored per-line qty/unit_price/vat (FR-2852), never stored — so no stale frozen total (inherits FRD-22). A SUM_LINES snapshot is the only place a total is frozen, and it captures the derived figure at snapshot time.
  • Idempotency: field create keyed (name, object) → dup submit 409, never double column. Record write is last-writer-wins per column (atomic); no partial JSONB.
  • Duplicate/out-of-order webhook: downstream dedupes on record id + slug + updated_at.

Backward-compat proof (feature OFF): With no [NEW] flags (options.indexed=false, no price_stale hint, no discount clamp/approval gate, no ISO/FX helpers, no optimistic lock), the create path is FieldCreateServiceTypeMapper('smart_catalog')='jsonb'SchemaGenerator.addColumn (config validated by validateFieldTypeOptions('smart_catalog', …) + validatePriceFieldConfig), and the write path is SmartCatalogFieldType.validatenormalizeLineItemsValue → per-pick permission-aware exact-identity resolution (browseSourceRecords/smart-catalog-filter) → arity/qty/price/VAT checks → snapshot unit_price → serialize canonical JSONB → read enrich to {resolved_label, line_total, source_removed}byte-identical to today. Every [NEW] (GIN index, price_stale hint, discount clamp/approval, FX, optimistic lock) is opt-in and defaults off, so the untouched path is unchanged.


13. Open Questions & Risks

  • OQ-1 (snapshot vs live — the big one). Baseline freezes unit_price at selection (FR-2851). Confirm the product rule: is a quote always frozen (recommended, invoice immutability), or should a "live pricing" mode exist for draft quotes that re-reads the catalog until the quote is "sent"? Pin whether editing the record (not the item) ever re-reads price (recommend never). Owner: Product + Finance. Highest-leverage decision.
  • OQ-2 (source delete policy). When a source record or the whole source object is deleted, do we (a) keep lines as source_removed (baseline, recommended), (b) block the delete while referenced, or (c) require an explicit orphan confirm? Recommend (b) for the whole-object case (SEC-U3). Owner: Product.
  • OQ-3 (price_editable governance). Should overrides above a discount threshold require approval (a Synapse approval gate), and should we clamp unit_price to a min (never below cost)? (SEC-2805/U2.) Owner: Product.
  • OQ-4 (currency source). Where does a line's currency come from — the source's currency field, a field-level options.currency, or the host record? Pin it so SUM_LINES and PDFs agree and mixed-currency is detectable (SEC-2808/U5). Owner: Backend + Product.
  • OQ-5 (rounding). decimal_precision vs per-currency dp (KWD=3): which wins, and is rounding half-up or banker's? Line-total-then-sum vs sum-then-round can differ by a cent — pin the order. Owner: Finance + Backend.
  • OQ-6 (indexing). Is a [NEW] GIN index on the JSONB worth it for "which invoices reference product X" (revenue-by-product, catalog-changed workflows), or is a generated join table better? Owner: Backend.
  • OQ-7 (multi cap). What is the max line count per value (SEC-2806)? A real invoice rarely exceeds ~200 lines; pin a cap that protects enrichment/PDF without blocking legitimate large orders. Owner: Product.
  • RISK-1. Permission-aware browse (SEC-2801/U4) is the highest-severity access gap: if browse OR write-validation misses the source A/G/M/D filter, a confidential catalog leaks. Both paths must go through browseSourceRecords/exact-identity resolution — audit any new picker/write path.
  • RISK-2. price_editable fraud (SEC-2805/U2) is the highest-severity money gap: unaudited overrides let a rep zero-out or under-bill. Audit + (ideally) approval-gate before trusting overridden prices in finance.
  • RISK-3. Source-removed everywhere (SEC-2809/U3) — every consumer (PDF, export, dashboard, SUM_LINES) must handle source_removed or a historical invoice breaks. Contract-test the flag across all read boundaries.
  • RISK-4. Mixed-currency blind sum (SEC-2808/U5) — a headline grand total across currencies is meaningless; ship the one-currency invariant + group-by-code before surfacing any total.

14. Exhaustive Scenarios & Edge Cases

14.1 Persona A — CHAOS user

SCInput / actionExpected result
SC-A01Pick one valid item (single mode)stored SmartCatalogSingleValue; read enriches label (FR-2810/2840)
SC-A02Submit array of 3 in single mode422 TOO_MANY_ITEMS (FR-2834, E4)
SC-A03source_record_slug not in source422 INVALID_CATALOG_ITEM (FR-2831, E1)
SC-A04Forge a valid slug the user can't read422 INVALID_CATALOG_ITEM (existence not leaked) (FR-2832, SEC-2801, E2)
SC-A05Slug from another workspace422 — not resolvable on pinned conn (SEC-2803, E3)
SC-A06quantity:-5422 INVALID_LINE_ITEM (FR-2833, SEC-2804, E5)
SC-A07quantity:1e12422 (over cap) (FR-2833, SEC-2806, E18)
SC-A08unit_price:-10 override422 always (FR-2833/2835, E7)
SC-A09Override price while price_editable=falseoverride ignored; snapshot = catalog price (FR-2835, E6)
SC-A10Override price while price_editable=trueaccepted and audited (before/after vs catalog) (SEC-2805)
SC-A11vat_percent:250422 (0..100) (FR-2833, SEC-2807, E8)
SC-A12Legacy bare-array value on readnormalized {rows,vat_percent:0} (FR-2812, E10)
SC-A13Empty {rows:[]}stored NULL (FR-2813, E9)
SC-A14Referenced source record deleted after selectionline survives; source_removed=true; total preserved (FR-2841, E11)
SC-A15Whole source object deletedall lines source_removed; value preserved; field flagged (SEC-U3, E12)
SC-A16Mixed-currency lines (USD+KWD)flagged/blocked; no blind total (FR-2853, SEC-2808, E17)
SC-A17__proto__ key in a crafted JSONB valuedropped — canonical allow-list only (SEC-2812)
SC-A18Client sends line_total/resolved_label/source_removed in the writedropped — enriched keys are read-only, recomputed (SEC-2811)
SC-A19'; DROP TABLE fields;-- as field namesanitized identifier, safe (SEC-2813)
SC-A20Search the picker for a forbidden product's exact SKUabsent from results (search inside A/G/M/D) (SEC-U4)
SC-A21KWD line, 100.125 × 2line_total at 3 dp; never truncated to 2 (FR-2853, FRD-22 SEC-2203)
SC-A22Rapid 50 edits/sec to the same fieldeach atomic; last value wins; audit each; no corruption
SC-A23Offline submit then reconnect (double-fire)last-writer-wins per column; no dup row
SC-A24300 lines in one valuecapped/TOO_MANY_ITEMS; enrichment batched (SEC-2806, OQ-7)

14.2 Persona B — NORMAL user

SCInput / actionExpected result
SC-B01Add 3 products, set qty, VAT 15%subtotal = Σ(qty×price), VAT, grand total computed (FR-2852)
SC-B02Leaves optional catalog field emptyNULL, no error (required=false)
SC-B03Edits a line's qty laterline_total + totals recompute; audit before/after
SC-B04Required catalog left blank on create422 REQUIRED_FIELD (FR-2837)
SC-B05Re-picks an item after a price changeunit_price re-snapshotted to live price (FR-2851)
SC-B06Views an old quote after a catalog price riseshows the frozen quoted price + [NEW] "price changed" hint (FR-2851, SEC-2810)

14.3 Persona C — PRO / ADMIN

SCInput / actionExpected result
SC-C01Configure line-items with a numeric price_fieldcreated (FR-2825)
SC-C02Configure line-items with a text price field422 INVALID_PRICE_FIELD (FR-2825, A4)
SC-C03Point at a non-existent source object422 INVALID_SOURCE_OBJECT (FR-2821, A3)
SC-C04Deactivate fieldcolumn kept, hidden; values intact
SC-C05Delete non-system smart_catalog fieldcolumn dropped; dependent SUM_LINES calc flagged (FR-2880)
SC-C06Delete is_system smart_catalog field403 blocked (E16)
SC-C07Change source price_field to non-numeric while referencedblocked / field flagged; math falls back to snapshot (SEC-U6, E13)
SC-C08Enable [NEW] GIN indexindex created; "invoices referencing product X" queries fast (FR-2826)
SC-C09Bulk import 10k invoices with linesslug refs resolved; unknown items reported; @TenantScoped pinned (SC-IMP-*)

14.4 Cross-feature interactions — EVERY place a smart_catalog field touches inside Corteksa

The living, exhaustive map. Each row is a real case with a concrete expected result. New cases append here forever. IDs namespaced by area.

14.4.1 Core engines

SCInteractionExpected
SC-X01SUM_LINES in CALCULATION aggregates the line-items fieldΣ(qty×price) (+VAT) from frozen amounts; source_removed lines still count (FR-2854)
SC-X02Document Template renders the fieldthe invoice/quote line-items TABLE (labels, qty, unit price, line total, subtotal, VAT, total) — never slugs (FR-2840, STAR)
SC-X03catalog-changed fires a Synapse triggerbefore/after passed to workflow (FR-2870)
SC-X04Deduplication compares two quotesline sets compared by resolved identity; merge rule keeps/combines lines
SC-X05Audit log of a line edit / price overridebefore/after stored; override specifically audited (SEC-2805)
SC-X06An invoice's serial_number field (FRD-29)invoice number issued alongside the catalog lines

14.4.2 Records: list / search / views / kanban / folders / move / tasks / snapshots

SCInteractionExpected
SC-REC-01List view cellsummary chip "3 items · SAR 1,250.00" (resolved, not slugs) (UX-2806)
SC-REC-02Filter "references product X"JSONB containment on source_record_slug; parameterized (FR-2843, SEC-2813)
SC-REC-03IS_EMPTY / IS_NOT_EMPTYmatches NULL / non-NULL (FR-2843)
SC-REC-04Kanban group-by a smart_catalogrejected as a group key (not select/status); UI hides it as a group option
SC-REC-05Sort by the fieldnot a meaningful sort key (JSONB); UI offers sort by the SUM_LINES total instead
SC-REC-06Record-move to an object without the source configuredvalue flagged in move report (target has no matching field) — not silently dropped
SC-REC-07Versioned edit snapshots prior lines to _snapshots JSONBold lines retained for restore
SC-REC-08Public read path (isPII=false)enriched labels/totals returned; forbidden source fields not projected

14.4.3 Export / Import (Excel, CSV, Bull processors)

SCInteractionExpected
SC-IMP-01Export a line-items fieldlines as rows (one row per line: label, qty, unit price, line total) + a totals row; labels not slugs (FR-2840)
SC-IMP-02Import lines from rowseach source_record_slug resolved against the source; unknown item → row reported, others commit (partial)
SC-IMP-03Import a line for a forbidden source recordrejected permission-aware (SEC-2801); reported
SC-IMP-04Export a line_total cell starting =/+/-/@CSV-injection prefix ' (inherits FRD-01 SEC-11)
SC-IMP-05Import 100k invoices on hyper via Bull@TenantScoped pins workspace; source resolution RLS-scoped; zero bleed (SEC-2803)
SC-IMP-06Import a line with source_removed sourcestored with frozen price; flagged removed on read
SC-IMP-07Round-trip export→import of a quoteslug refs + qty + unit_price + vat preserved; totals recompute identically

14.4.4 Document Templates & Puppeteer PDF (the STAR interaction)

SCInteractionExpected
SC-TPL-01Render the invoice PDFa real <table>: rows (label, qty, unit price, line total), subtotal, VAT, grand total — the classic quote/invoice (FR-2840/2852)
SC-TPL-02A source_removed line in the PDFrenders label "(removed)" + its frozen line_total; total unchanged; no crash (FR-2841)
SC-TPL-03KWD invoiceamounts at 3 dp everywhere; never truncated to 2 (FR-2853)
SC-TPL-04Arabic/RTL invoicetable RTL, amounts LTR, symbol placement per locale (FRD-22 UX)
SC-TPL-05Template references a source field the viewer can't seeonly display_fields projected; forbidden field blank/omitted (SEC-2803-leak)
SC-TPL-06XSS in a resolved label (crafted source record name)Handlebars auto-escapes; shown literally (inherits FRD-01 SEC-05)
SC-TPL-07Empty catalog value in a templateblank table / "No items", never literal null/slugs
SC-TPL-08Long line list overflows a pagetable paginates across PDF pages; totals on the last page

14.4.5 CALCULATION engine

SCInteractionExpected
SC-CALC-01SUM_LINES(catalog_field) → a currency Total fieldΣ(qty×unit_price) at per-currency dp; VAT per config (FR-2854)
SC-CALC-02A source_removed line in SUM_LINESits stored amount still contributes (total doesn't drop) (FR-2841)
SC-CALC-03Mixed-currency lines fed to SUM_LINESblocked/flagged; no blend (FR-2853, SEC-2808)
SC-CALC-04Edit a line → recalc of the dependent Total fielddependency-graph + recalculation.service recompute in the caller's tx
SC-CALC-05Delete the catalog field referenced by SUM_LINESFR-2880 flags the dependent calc; it evaluates null per null-handling, no crash
SC-CALC-06Rename the catalog field columnformula AST is slug-keyed → unaffected (inherits FRD-01 SC-CALC-07)
SC-CALC-07Rounding order (line-then-sum vs sum-then-round)pinned deterministic order; matches the PDF (OQ-5)

14.4.6 Workflow / Synapse & Webhook module

SCInteractionExpected
SC-WF-01catalog-changed trigger (line added/removed)before/after lines passed; before==after → no fire
SC-WF-02generate-records from linesone child record per line (e.g. a fulfillment/order-item record), each carrying the resolved item + qty (FR-2870)
SC-WF-03update-field action sets a catalog valuevalidated identically to the UI path (permission-aware, sanity checks)
SC-WF-04send-webhook with a catalog valueJSON-encoded with resolved labels + line totals (not bare slugs)
SC-WF-05Approval gate on a large discount override ([NEW])override above threshold pauses for approval (SEC-2805/U2)
SC-WF-06Workflow condition IS_EMPTY on the catalogmatches NULL (FR-2813)
SC-WF-07Duplicate webhook deliverydownstream dedupes on record id + slug + updated_at

14.4.7 Messaging / Email / Lead-Ads

SCInteractionExpected
SC-MSG-01Send a quote summary over WhatsAppresolved labels + totals as plain text (no slugs, no HTML)
SC-MSG-02Email an invoice (PDF attached)the line-items PDF (SC-TPL-01) attached; body has resolved summary
SC-LEAD-01A lead-ad order form maps to a catalog fieldeach item resolved permission-aware; unknown item reported in LeadProcessingProcessor

14.4.8 AI module (v1 engine + v2 LangGraph)

SCInteractionExpected
SC-AI-01AI tool builds a quote (adds lines)validated same path; confirmation gate; source_type='ai'; price snapshot rules apply
SC-AI-02AI reads a quote as contextenriched labels + totals returned; forbidden source data not exposed (SEC-2801)
SC-AI-03AI proposes overriding a pricesubject to price_editable + audit + [NEW] approval (SEC-2805)
SC-AI-04Workspace-builder proposes a smart_catalog field in a plancreated via FieldCreateService on builder:confirm; source object must exist first

14.4.9 Dashboards / Analytics

SCInteractionExpected
SC-DASH-01Revenue by product (group-by referenced source record)resolves source labels; group-by-currency; no mixed blind sum (FR-2853, SEC-2808)
SC-DASH-02Top-selling catalog itemsaggregates qty across quotes; source_removed items still counted by their frozen identity
SC-DASH-03Dashboard snapshot of total pipelinederived totals frozen at snapshot time (FR-2852)
SC-DASH-04Activity timeline of a quoteshows line add/remove/price-override events with before/after

14.4.10 Deduplication & Audit

SCInteractionExpected
SC-DEDUP-01Two duplicate quotes mergedline sets combined/deduped per merge rule; loser lines audited
SC-DEDUP-02Chat-mirror dedup touches a catalog valueaudited, workspace-pinned
SC-AUD-01null → value and value → null transitionsboth logged before/after
SC-AUD-02Price override auditbefore=catalog price, after=override, actor, source_type captured (SEC-2805)
SC-AUD-03Bulk import of 10k invoicesaudit batched to avoid log storm

14.4.11 Multi-tenant / isolation / field placement / duplication

SCInteractionExpected
SC-TEN-01Same catalog field name in two hyper workspacesallowed — composite unique + RLS isolation
SC-TEN-02Cross-workspace source resolution attemptRLS: not resolvable → 422 (SEC-2803)
SC-TEN-03Worker resolves source records without pinning on hyperRLS ⇒ zero rows; guardrail spec catches static getDataSource() (SEC-U7)
SC-TEN-04Dedicated tenantno RLS; source resolution via DB boundary; works unchanged
SC-PLC-01Duplicate the field to another host objectnew independent field + column; source config copied; existing values not shared
SC-PLC-02Move a host record between objectscatalog value re-validated against target's field config; mismatch flagged in move report

14.4.12 Encoding / lifecycle / migration

SCInteractionExpected
SC-ENC-01Migrate smart_catalog → textrender a human summary (labels+totals) or block; never dump raw slugs (SEC-2815)
SC-ENC-02Migrate single → multisafe widen (wrap the single value in a 1-element array) (SEC-2815)
SC-ENC-03Migrate smart_catalog → relationdrops price/qty/vat — blocked/cast-and-report (SEC-2815)
SC-ENC-04Object soft-delete then restorecatalog values retained and restored; source_removed re-evaluated on read
SC-ENC-05Object hard-delete (host)onDelete CASCADE drops the field + column
SC-ENC-06S3 backup → restore round-tripJSONB byte-identical; slug refs + snapshots preserved
SC-ENC-07Rename source field used as price_fieldslug-keyed config unaffected; math stable

14.4.13 Public API / forms / rate-limiting

SCInteractionExpected
SC-PUB-01Public order form picks from a public catalogreCAPTCHA verified; permission-aware browse (public scope); validated same path
SC-PUB-02Public GET returns a catalog valueenriched labels/totals (isPII=false); forbidden source fields not projected
SC-PUB-03Flood public submits@nestjs/throttler → 429
SC-PUB-04Public submit with 10⁴ lines413 / line cap (SEC-2806)

14.5 Quick resolution matrix (riskiest inputs)

Riskiest inputResult
forged/forbidden source_record_slug422 INVALID_CATALOG_ITEM (permission-aware) (SEC-2801)
cross-workspace source slug422 — RLS not resolvable (SEC-2803)
numeric id instead of slugrejected — slug-only API (SEC-2802)
>1 pick in single mode422 TOO_MANY_ITEMS (FR-2834)
negative / huge quantity422 INVALID_LINE_ITEM (SEC-2804)
negative unit_price override422 always (SEC-2805)
price override while price_editable=falseignored; snapshot = catalog price (FR-2835)
price override while price_editable=trueaccepted + audited (SEC-2805)
VAT out of [0..100]422 (SEC-2807)
mixed-currency linesblocked/flagged; no blind sum (SEC-2808)
source record deleted after selectionline survives; source_removed; total frozen (SEC-2809)
whole source object deletedall lines source_removed; value preserved; block/repair (SEC-U3)
non-numeric price_field config422 INVALID_PRICE_FIELD (FR-2825)
stale snapshot vs live pricedeliberate frozen; [NEW] hint; re-pick refreshes (SEC-2810)
__proto__ / crafted JSONBcanonical allow-list only (SEC-2812)
client-sent line_total/resolved_labeldropped; recomputed (SEC-2811)
unpinned worker on hyperRLS zero rows / guardrail (SEC-U7)
duplicate field name409 (FR-2802)

Recurring trade-off (honest, 1–2 lines): smart_catalog deliberately embeds a priced snapshot instead of a live link (like relation), and stores the money inside a JSONB blob (like currency, not NUMERIC). The upside is invoice immutability and survival of deleted catalog items; the cost is that (a) totals must be recomputed by parsing/enriching (un-indexable, N+1 risk — mitigated by batch-resolve), (b) safety is a shared responsibility with every consumer (PDF, export, dashboard, SUM_LINES) which must all honor source_removed, permission-aware labels, and the one-currency invariant, and (c) snapshot-vs-live divergence must be surfaced deliberately, not silently. We accept this for financial integrity and centralize enrichment + permission-aware resolution at each read/consume boundary rather than trusting a live join.


15. Test Scenarios (Acceptance Checklist)

Fixture: host object invoice (table invoice); SOURCE object products (table products) with a numeric field price (slug=price_x1). Smart-catalog field Label "Line Items" → name=line_items, slug=line_items_ab12, column_name=line_items, column_type=jsonb, options=&#123; source_object_slug:'products', selection_mode:'multi', line_items_mode:true, price_field:'price_x1', quantity_default:1, price_editable:false, decimal_precision:2 &#125;. Products has widget_a1 (price 49.99) and widget_b2 (price 100.00). Golden example: write {"line_items_ab12": { "rows":[ {"source_object_slug":"products","source_record_slug":"widget_a1","quantity":2,"unit_price":49.99}, {"source_object_slug":"products","source_record_slug":"widget_b2","quantity":1,"unit_price":100.00} ], "vat_percent":15 }} → validate (both slugs exist + permitted; qty/price/vat sane; price_editable=false ⇒ snapshot = catalog price) → store canonical JSONB → read enrich: line1 line_total=99.98, line2 line_total=100.00, subtotal 199.98, VAT 29.997→30.00, grand total 229.98, both resolved_label set, source_removed=false.

  • T-2800 Create smart_catalog field → 201, column_type='jsonb', column exists, source resolved (FR-2800/2820/2821)
  • T-2801 Missing source_object_slug/selection_mode → 422 INVALID_OPTIONS (FR-2820, A2)
  • T-2802 Non-existent source object → 422 INVALID_SOURCE_OBJECT (FR-2821, A3)
  • T-2803 Permission-aware browse: user at D/M on source sees empty/own only; search never widens the set (FR-2860, SEC-2801/U4)
  • T-2804 Forged/forbidden slug on write → 422 INVALID_CATALOG_ITEM (existence not leaked) (FR-2832, SEC-2801/2802, E2)
  • T-2805 Negative/huge quantity → 422 INVALID_LINE_ITEM (FR-2833, SEC-2804)
  • T-2806 Price override: price_editable=false ⇒ ignored; true ⇒ accepted + audited; negative always rejected (FR-2835, SEC-2805)
  • T-2807 VAT out of [0..100] → 422 (FR-2833, SEC-2807)
  • T-2808 No raw slug leaks — PDF/CSV/message/list all render resolved labels (FR-2840, SEC-2803-leak)
  • T-2809 Golden example → totals subtotal/VAT/grand match to the minor unit (FR-2850/2852)
  • T-2810 Snapshot frozen: change catalog price after selection → stored unit_price unchanged; [NEW] price_stale hint; re-pick refreshes (FR-2851, SEC-2810, SC-B05/B06)
  • T-2811 source_removed: delete a referenced source record → line survives, label "(removed)", total preserved (FR-2841, SEC-2809, E11)
  • T-2812 Crafted JSONB (__proto__, deep nesting) → canonical allow-list only, no pollution (SEC-2812)
  • T-2813 Cross-workspace source slug + unpinned-resolution attempt → 422 / RLS zero rows (SEC-2803/U7, guardrail spec)
  • T-2814 Client-sent line_total/resolved_label/source_removed → dropped, recomputed (SEC-2811)
  • T-2815 Type migration smart_catalog→text renders summary/blocks (no raw slugs); single→multi safe widen (SEC-2815)
  • T-2816 Delete the whole source object while referenced → blocked/confirm; all lines source_removed if forced (SEC-U3, E12)
  • T-2817 Mixed-currency lines → blocked/flagged; no blind total (FR-2853, SEC-2808, E17)
  • T-2818 300-line value → capped/TOO_MANY_ITEMS; enrichment batched (single query), no N+1 (FR-2834/2842, SEC-2806)
  • T-2819 line_items_mode with a text price_field → 422 INVALID_PRICE_FIELD (FR-2825, A4, SC-C02)
  • T-2820 single mode + array of >1 → 422 TOO_MANY_ITEMS (FR-2834, E4)
  • T-2821 Legacy bare-array value on read → normalized {rows,vat_percent:0}, no crash (FR-2812, E10)
  • T-2822 KWD line → 3-dp math everywhere; never truncated to 2 (FR-2853, SC-A21)
  • T-2823 SUM_LINES calc → grand total incl. source_removed lines' frozen amounts (FR-2854, SC-CALC-01/02)
  • T-2824 Document Template → invoice line-items TABLE (labels, qty, unit price, line total, subtotal, VAT, total) (SC-TPL-01, STAR)
  • T-2825 Change source price_field to non-numeric while referenced → blocked/flagged; math falls back to snapshot (FR-2825, SEC-U6, E13)
  • T-2826 Delete is_system smart_catalog field → 403 (E16, SC-C06)
  • T-2827 Import 100k invoices on hyper via Bull → source resolution RLS-scoped, none bleed (SC-IMP-05, SEC-2803)
  • T-2828 generate-records-from-lines workflow → one child record per line with resolved item + qty (SC-WF-02)
  • T-2829 Export→import round-trip → slug refs + qty + unit_price + vat preserved; totals recompute identically (SC-IMP-07)
  • T-2830 Backward-compat OFF: all [NEW] flags off → create+browse+write+enrich path byte-identical to current build (§12)

Pass criteria: all T-28xx pass → feature works.

On this page

0. Delta-from-text at a glance (the only things that change)1. Overview & Goal2. Actors & Roles3. Pre-conditions & Assumptions4. Post-conditions5. Use Case Specification (Main / Happy Flow)5A. Create a smart_catalog field5B. Browse the SOURCE catalog (the picker — headline flow)5C. Write a smart_catalog value on a record5D. Read / enrich5E. Line-item math (commerce semantics — inherits FRD-22)5F. SUM_LINES rollup (CALCULATION — headline interaction, FRD-30)6. Alternative & Exception Flows7. User Flow Diagram (ASCII)7b. UX / UI / Simplicity Spec8. Data & State Model9. Business Rules Catalog10. Integrations & Dependencies11. Security Specification12. Reliability & Uptime (100% target)13. Open Questions & Risks14. Exhaustive Scenarios & Edge Cases14.1 Persona A — CHAOS user14.2 Persona B — NORMAL user14.3 Persona C — PRO / ADMIN14.4 Cross-feature interactions — EVERY place a smart_catalog field touches inside Corteksa14.4.1 Core engines14.4.2 Records: list / search / views / kanban / folders / move / tasks / snapshots14.4.3 Export / Import (Excel, CSV, Bull processors)14.4.4 Document Templates & Puppeteer PDF (the STAR interaction)14.4.5 CALCULATION engine14.4.6 Workflow / Synapse & Webhook module14.4.7 Messaging / Email / Lead-Ads14.4.8 AI module (v1 engine + v2 LangGraph)14.4.9 Dashboards / Analytics14.4.10 Deduplication & Audit14.4.11 Multi-tenant / isolation / field placement / duplication14.4.12 Encoding / lifecycle / migration14.4.13 Public API / forms / rate-limiting14.5 Quick resolution matrix (riskiest inputs)15. Test Scenarios (Acceptance Checklist)