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 notrelation (FRD-27) — that contrast
is a load-bearing part of this document (see §0).
browse() → browseSourceRecords filters/searches/paginates SOURCE records permission-aware
FR-2860, headline SEC
Primary CRM role
free string
the invoice/quote line-item builder on top of the dynamic-object system
FR-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 FieldTypeRegistry → SmartCatalogFieldType, 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.
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.
PRE-1. Parent (host) object exists with its dedicated physical table (SchemaGenerator.createObjectTable).
PRE-2. The SOURCE object named by options.source_object_slugexists 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.
New fields row (type='smart_catalog', column_type='jsonb', options with a validsource_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) success
browseSourceRecords 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 selection
Incoming 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 slug
422 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 later
Stored unit_priceunchanged (snapshot, FR-2851); enrichment may surface a [NEW]price_stale hint comparing stored vs live; re-picking the item refreshes the price.
Field deleted
fields 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.
Admin opens the host object → Add field → selects Smart Catalog.
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.
Submit → POST /object/field/:objectSlug (CreateFieldDto, type='smart_catalog', options).
System validates DTO + validateFieldTypeOptions('smart_catalog', options) (requiressource_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.
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-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).
In the item picker the editor types a query / applies a filter.
GET /object/field/:slug/smart-catalog/browse (browse-catalog.dto.ts) → SmartCatalogController →
SmartCatalogService.browse() → SmartCatalogQueryService.browseSourceRecords.
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.
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.
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.
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.
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-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.
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 survive — line_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).
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.
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).
source_object_slug doesn't exist / other workspace
create
422 INVALID_SOURCE_OBJECT (RLS-invisible on hyper)
FR-2821, SEC-2803
A4
line_items_mode=true but price_field non-numeric
create → validatePriceFieldConfig
422 INVALID_PRICE_FIELD, no column
FR-2825
A5
display_fields names an unknown source field
create
422 INVALID_OPTIONS
FR-2822
E1
Write a source_record_slug not in the source
record write
422 INVALID_CATALOG_ITEM {slug}, no write
FR-2831
E2
Write a source slug the caller may not read (level D/out of scope)
record write
422 INVALID_CATALOG_ITEM (existence not leaked)
FR-2832, SEC-2801
E3
Write a slug from another workspace
record write
422 (RLS: not resolvable on the pinned conn)
SEC-2803
E4
selection_mode='single' but array of >1 pick submitted
record write
422 TOO_MANY_ITEMS {slug}
FR-2834
E5
Negative / huge quantity
record write
422 INVALID_LINE_ITEM (qty ≥ 0, ≤ cap)
FR-2833, SEC-2804
E6
unit_price override while price_editable=false
record write
override ignored; snapshot = catalog price
FR-2835
E7
Negative unit_price (override)
record write
422 INVALID_LINE_ITEM (always rejected)
FR-2833, FR-2835
E8
vat_percent = 250 (out of range)
record write
422 INVALID_LINE_ITEM (0..100)
FR-2833, SEC-2807
E9
Empty / [] / {rows:[]}
record write
stored NULL (empty ≡ absent)
FR-2813
E10
Legacy bare-array value on read
read/enrich
normalizeLineItemsValue → {rows:[…], vat_percent:0}; no crash
FR-2812
E11
Referenced source record deleted after selection
read/enrich
line survives; source_removed=true, label "<name> (removed)"; total preserved
FR-2841
E12
Source object deleted entirely
read/enrich
all lines source_removed=true, label falls back to bare "(removed)" (table dropped ⇒ no name to recover); value preserved; field flagged for admin repair
FR-2841, SEC-U3
E13
Source price_field later changed to a non-numeric type
source field edit
blocked while a smart_catalog references it as price_field, or field flagged inactive; line math falls back to stored snapshot
FR-2825, SEC-U6
E14
Permission denied on the host object (level D)
create/update record
403 route gate before body runs
SEC-2801-acl
E15
DDL ADD COLUMN … jsonb fails
tx aborts
5xx, metadata+DDL rolled back, no orphan
FR-2828
E16
Delete a is_system smart_catalog field
delete
403 SYSTEM_FIELD_IMMUTABLE, no drop
FR-2880
E17
Mixed-currency lines
read/aggregate
blocked/flagged as meaningless; never blind-summed
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 / click
Justified?
Removable?
1
Click "Add field"
yes — entry
no
2
Pick "Smart Catalog" tile
yes — type choice
no
3
Type Label
yes — required
no
4
Pick Source object + selection mode
yes — a catalog picker needs a catalog (PRE-2)
no
5 (opt)
Toggle "Line items" → price field + defaults
only for invoices/quotes
yes — off = plain picker
6 (opt)
display_fields / price_editable / dp
advanced, collapsed
yes
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).
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).
SEC
Threat
Attack vector
Mitigation (by design)
maps-to
SEC-2801-acl
Broken access control (host)
edit a quote without object rights
PermissionGuard route gate update.{slug} + A/G/M/D row filter on the HOST object
FR-2837, E14
SEC-2801
Permission-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 see
browseSourceRecords 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 conn
FR-2860, FR-2832, E2, T-2803/2804
SEC-2802-idor
IDOR via forged source_record_slug
pass a numeric id or a guessed slug to reach another record
API 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-2803
Cross-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 path
source 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-leak
Raw slug / forbidden data leak on render
an 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 see
enrichment 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-tested
FR-2840, T-2808
SEC-2804
Quantity 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 type
FR-2833, E5, T-2805
SEC-2805
price_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-bill
when 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 discounts
FR-2827/2833/2835, E6/E7, T-2806
SEC-2807
VAT tampering
vat_percent:0 to dodge tax, vat_percent:250 to inflate, or a non-numeric VAT
vat_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-2806
Enrichment N+1 / unbounded browse (DoS)
a value with 10⁴ lines, or a browse that dumps the whole catalog → N+1 source queries, OOM, slow render
line 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-2808
Mixed-currency lines sum to nonsense
lines 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-2809
Deleted source breaks a historical total
a discontinued product's line silently drops → the invoice total changes / the PDF crashes
source_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 gaps
FR-2841, E11/E12, T-2811
SEC-2810
Stale 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 silent
FR-2851, T-2810, UU-1
SEC-2811
Mass assignment
POST is_system/workspace_id/column_type, or extra JSONB keys (line_total, resolved_label, source_removed) hoping they persist
DTO 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-2812
JSONB proto-pollution / crafted value
a value with __proto__/constructor keys or deeply-nested JSON to pollute prototypes or blow the parser
value 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 merged
FR-2810, T-2812
SEC-2813
SQL/JSONB injection via field name or slug
name="x; DROP…"; or a crafted source_record_slug string in a JSONB filter
sanitizePgIdentifier on identifiers; slugs resolved to a closed set (source records) via parameterized TypeORM JSONB queries — no string interpolation
FR-2807/2831
SEC-2814
Audit bypass
write the JSONB column outside the API
all 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-2815
Unsafe type migration
smart_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-U1
Unknown-unknown: snapshot silently diverges from the catalog
catalog 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 prices
FR-2851, SEC-2810, T-2810
SEC-U2
Unknown-unknown:price_editable lets a rep zero-out an invoice for fraud
rep sets every unit_price to 0.01 or 0 to under-bill a colluding customer
audit every override (before/after vs catalog); [NEW] discount clamp + approval gate above a threshold; report on aggregate override deltas per rep
FR-2835, SEC-2805, T-2806
SEC-U3
Unknown-unknown: the whole SOURCE object is deleted, not just a record
admin deletes "Products" → every quote's every line dangles
all 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 repair
FR-2841, E12, T-2816
SEC-U4
Unknown-unknown: permission-aware browse leaks a confidential product list via search
a rep can't list Product X but searches its exact name/SKU and infers existence from a hit/no-hit
search 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 oracle
FR-2860/2861, T-2803
SEC-U5
Unknown-unknown: mixed-currency total shown as a headline figure
a quote mixes SAR and USD lines; the grand total reads "1,300" of nothing
one-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-U6
Unknown-unknown: source price_field type flipped to non-numeric mid-life
admin edits the Products "price" field from number to text → new line math breaks
block 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-U7
Unknown-unknown: a forged source_record_slug from another workspace resolves via a non-RLS path
a background job or a helper that reads source records with getDataSource() (unpinned) resolves a foreign slug, bypassing RLS
ALL 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 → 422
block source delete while referenced (SEC-U3) / repair
immediate
validatePriceFieldConfig fails
non-numeric price field
create/edit time
reject config; no field created
admin picks a numeric field
immediate
Enrichment lookup (N sources)
slow / N+1
read timer
batch-resolve all slugs in one query (FR-2842)
tune / [NEW] GIN index
immediate
SUM_LINES engine down
total field not recomputed
calc queue
stored lines intact; total recomputes when up
Bull retry (FRD-30)
seconds
PDF renderer down
invoice PDF not produced
render error
data intact; regenerate on demand
retry
seconds
Browse/query heavy
large source scan
query timer
bounded pagination (FR-2861); [NEW] index
tune
minutes
Import queue backlog
line rows delayed
queue depth
Bull persists; @TenantScoped retry
retry+backoff
minutes
Audit subscriber slow
override log lag
queue depth
value committed; audit eventual
eventual flush
seconds
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).
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 FieldCreateService
→ TypeMapper('smart_catalog')='jsonb' → SchemaGenerator.addColumn (config validated by
validateFieldTypeOptions('smart_catalog', …) + validatePriceFieldConfig), and the write path is
SmartCatalogFieldType.validate → normalizeLineItemsValue → 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.
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.
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.