AISA v2.0 / Technical documentation / pc_agent_9951_artifact_extract.txt
pc_agent_9951_artifact_extract.txt
text · 5480 lines · 369,632 bytes · wiki path 90 Reference/pc_agent_9951_artifact_extract.txt · download the raw file · cited from Factor sources · Quote verification · Rating files · Shared vocabulary · Translations · Write envelopes · Product Configurator · Schema quirks
Inside the Product Configurator
-
-
-
Serdica · product configuration
Inside the Product Configurator
This agent configured product 9951, «Каско Максимум» — a motor casco product — end to end on dev: reading the insurer's own tariff and product template, applying the catalogue, rating and offer configuration, and driving the sales wizard until a real premium rendered and reconciled against the tariff. The page below documents how the agent is built — eight layers, 3 procedures, 20 knowledge documents, 8 operating rules, 2 isolated sub-agents, 3 consulted agents and 9 scripts — and which parts reference which. The source documents it worked from are reproduced in full at the bottom.
The reference map
Read it top to bottom: the source documents enter, the procedures act on them under a set of standing rules, and everything below is what they draw on and what they reach. Every edge was extracted by scanning the workspace for what each file actually names — none is asserted.
Three things are worth reading off it. The main procedure is the hub — it reaches three of the four knowledge groups, both write paths and two consulted agents, while the other two procedures are deliberately narrow. Everything funnels through one band: no layer below the scripts is reachable except through them, and that band opens onto exactly one environment. And the configuration layers are ordered — offers resolve before rating, which is why a correct tariff can still refuse to quote.
SOURCES
-
the tariffbase rates by clause and vehicle age,loadings, discounts, taxesthe product specificationcovers, clauses, packages, dependencies,premium rules, underwriting questionnairePROCEDURESwhat it follows
-
configure-productthe path for any configuration taskexplore-schemawhen the guides are silent or wrongroundtrip-testmeasures the agent against a known product
RULESstanding constraints
-
· evidence· reuse-before-create· unknown-contracts· missing-information· writes· verification· no-local-db-copies· knowledge-upkeep
KNOWLEDGE20 documents, loaded by name
-
orientationREADMEthree-domainsboundariesthe layersipal-configurationpricing-factorsoffers-engineanalytics-pparating-tablesabacus-engine-referenceshared-vocabularytranslationsmethodconfiguration-orderproduct-modelinput-contractdiscovering-a-productwriting-configurationtesting-a-productmaking-a-product-issuableground truthschema-quirksenvironments
SPECIALISTSscoped context, and other systems
-
tariff-analystsub-agentno database accessgradersub-agentno provenancebackendconsultedprocesses, engine, filtersfrontendconsultedrendering, the browserdbconsultedlive data evidence
SCRIPTSthe only executable path
-
readpcmcpexport_productwritepcwriteapply_modelrateabacuspcgwmodeldiff_modelclone_productdocumentsread_sources
ACCESSone environment, enforced
-
scoped database instancedev only · refuses any other before a connection opensthe workspace holds no credentialspolicies · claims · customers never writableSerdica gatewaydev only · refuses any other before a request is builtroutes resolved live, never hardcodedpricing calls compute and store nothing
CONFIGURATION LAYERSresolved in this order at quote time
-
cataloguethe product, covers,definitions, limitspricing factorswhat is asked, andwhich value it takesofferspackages, dependencies,conflicts — resolved FIRSTratinggrids, versions, loadings,discounts, taxesvisibilitywhere it appears,and to which roles
OUTCOME
-
a product a customer can be quoted a price foroffer resolution runs before the rating engine — a tariff reconciled to the cent still refuses to quote if the packaging is absent
Eight layers. The two access boxes are the only way out of the workspace, and both are bounded to a single environment — the database instance refuses any other before a connection opens, the gateway before a request is built, and neither can be widened from inside. Dashed edges are constraints and consultations; solid ones are the working path.
Why the source documents stay a first-class input
A natural question is whether, given a complete tariff, the rest of the configuration is uniquely determined. The answer the team settled on is partially — and that is precisely why the agent reads the paperwork rather than only mirroring the rating side.
What the tariff decides, and what it cannot
| | Derivable from the tariff | Not derivable — read from the requirements
| Which covers are rated; which factors the rating keys on; the loading and discount structure; the version and template shape.
| Cover names and how definitions should read. Offer packaging. Everything in the customer requirements — "only users from group 6 see this product", when a cover may be taken, and the like.
So the procedure is: derive what is derivable, read the rest from the requirements, and list what is in neither — that third list is a deliverable, not an admission.
How the documents are read
The source files arrive as a spreadsheet, a PDF and sometimes a Word requirements document. There is no bespoke parser per file — one script renders their structure and text, and the agent reads them the way a person would. The pricing half is then handed to a sub-agent that has been given the documents and nothing else.
"Това умение на ABACUS е напълно отделен агент — няма нужда да знае нищо [за базата]. Базата е просто една реализация на таксономията."
— the team's rationale, carried in the sub-agent's own instructions
The tariff reader is told that if it finds itself wanting to know a table name, it has drifted out of its job. The database is one realisation of the taxonomy, not the taxonomy itself — so the pricing structure gets read out of the document on the document's terms, and is only afterwards mapped onto storage by something else.
What the tariff reader must produce for every item it finds
| | For each rated cover | For each loading, discount, tax or fee
| The shape — a flat rate, a conditional rate, or a grid. For a grid: the row factor, the column factor, the value labels on each axis, and the rates. The base — what the rate is a percentage of: sum insured, base premium, or a fixed value. Minimum and cap where stated.
| The sign. The dimension — percentage, fixed value, or relativity multiplier. What it applies to. The order it applies in, and whether the adjustments sum or multiply — these give different answers and the document usually only implies which. The rule quoted in the document's own words.
Everything is cited to file, sheet and range. Three disciplines are attached to the job, each aimed at a known way it fails:
- Count the layers, and say soThe recurring error is miscounting — layers, covers, discounts. Before finishing, state how many the document contains and how many were produced, with the evidence for the count. A mismatch is the finding, not something to smooth over.
- Do not flatten a non-linear ruleSome rules cannot be a grid without an enormous table or a tree of conditions. Describe the rule in prose, mark it as needing custom handling, and hand it on — an approximate grid prices wrongly and does so silently.
- Ambiguity blocks, it does not get inferredSeveral tariff files with no legend saying which cover each belongs to is a blocking gap. Say which files exist, what could and could not be attributed, and stop.
The two contracts it works to
input-contract.md
What each document is authoritative for
The operator product template runs to eleven sheets — covers, clauses, packages, cover dependencies, premium rules, the underwriting questionnaire. This document says which sheet settles which question, documents a second template shape in circulation, and names the gap in the legend. It also treats verifying a parsed tariff as the hard problem it is.
product-model.json
The single structured form everything passes through
Documents in, configuration out, and this in between — so the reading of a tariff can be reviewed before anything is written. Its spec is designed against a specific recurring error, the layer miscount. Two fields carry weight: reconciliation, which is mandatory, and unresolved, which flows out to a written record of what could not be determined.
Each product also leaves a fixed set of working papers: the plan, the write log, the run log, the build state, and the record of deliberate omissions. Those are what make a run resumable rather than repeatable, and what make a handover reviewable.
The procedure
Before any of it, three questions decide whether the work can land at all — settled against the running system, early, because running them after building is how a correct configuration ends up stranded at the last step.
- Model the sourcesThe tariff and the specification become one structured product definition in business terms. The tariff half is delegated to a sub-agent that is given no view of the database.
- Reconcile against what existsCover codes, object codes, factor codes and message codes are platform-wide vocabulary, not names local to this product. Check before creating any of them — every time.
- Apply, in dependency orderA row cannot reference something that does not exist yet: lookups before the rows that use them, parents before children. Twelve steps, of which the dependency part is not negotiable and the sequence is a guideline.
- The donor diff — a gate, not a reviewCompare the finished product against one that already sells, entity by entity, and account for every difference. Nothing may be reported complete until this passes. This stage exists because of a specific failure, described below.
- Verify numericallyObtain a premium and reconcile it against the tariff by hand, one axis at a time. Under a summed structure a configuration broken in four places can still total correctly.
- Prove it in the interfaceDrive the real quotation journey until a premium renders. Rows landing in tables is not evidence that the product works.
And route to the minimum. Most requests are not a full build: a rate change touches rating only; adding a cover touches the catalogue only; "why doesn't cover X appear for this customer?" touches nothing at all — it is an investigation. Configuring everything every time is slow and produces changes nobody asked for.
Which layer each part of the system handles
A product is assembled layer by layer, and each layer has its own reference document, its own write path, and its own way of failing. Steps through the catalogue are one coherent unit of work; pricing factors, rating and offers are separable, which is what makes a partial request — a rate change, an added cover — cheap.
| | Layer | What it decides | Reference | Consult
| Catalogue
IPAL | The product itself, its names, objects, covers, cover definitions where limits and deductibles live, risks, events, loadings, documents and annexes. | ipal-configuration
configuration-order | —
| Pricing factors | What the operator is asked, the datatype of each answer, which level it sits at, and which dropdowns depend on which. An object's own attributes are pricing factors too — and a new product has none. | pricing-factors | frontend
| Rating
ABACUS | Versions and their lifecycle, the rate grids themselves, loadings, discounts and taxes, and how the engine composes them into a premium. | analytics-ppa
rating-tables
abacus-engine-reference | backend
| Offers | The packages a product is sold as, dependencies and conflicts between covers, and limit restrictions. Resolved before rating — which is why a correct tariff can still refuse to quote. | offers-engine | —
| Visibility | Where the product appears, and which roles can see it. Invisibility is usually a route at an ancestor level, not the product row. | ipal-configuration
making-a-product-issuable | frontend
| Issuance | Whether an existing sales process can carry this product's shape, and where the wizard steps come from. The end of the configuration-only path lives here. | making-a-product-issuable
boundaries | backend
| Vocabulary
& labels | Cross-cutting. Which codes are platform-wide and must be reused rather than minted, and how every visible name resolves through to per-language text. | shared-vocabulary
translations | —
What it knows, and when each part is consulted
Twenty documents, deliberately topic-sliced so that a job loads the one it needs rather than the whole library. All of it is held as prior, not law — every file is distilled from previous runs and can be stale or simply wrong for the product in front of you. The live schema, the live code and the operator's document win, and correcting a guide that misled you is part of finishing the work.
| | Group | Documents | What it settles
| Orientation
| three-domains
boundaries
README
| The map of where a product lives, and the shape of the estate — which products are genuinely sellable and therefore safe to copy from. And the harder one: where to stop, and how to hand over a boundary as a finished piece of work.
| The domains
| ipal-configuration
pricing-factors
offers-engine
analytics-ppa
rating-tables
abacus-engine-reference
shared-vocabulary
translations
| One per area of the platform, plus a reference on the rating engine derived from its own source. Two are cross-cutting: which codes are shared vocabulary that must be reused rather than minted, and how every visible name resolves through to per-language text.
| Doing the work
| configuration-order
product-model
input-contract
discovering-a-product
writing-configuration
testing-a-product
making-a-product-issuable
| The order of operations and the routing table; the model and document contracts; the three write paths and what each error actually means; how to prove a configuration works and what each level of proof does not establish; and the end-to-end delta that makes a product issuable.
| Ground truth
| schema-quirks
environments
| Observed naming inconsistencies and filter syntax — explicitly marked as observations rather than truth, to be re-checked against the live schema. And the access posture: one environment — dev — on both paths, enforced at the scoped database instance and at the gateway gate rather than by convention.
How the parts point at each other
The library is not a pile of documents; it is a routed one. The index alone points at 18 of the 20 knowledge files, and the procedures and rules name the specific document, script or specialist that settles each question rather than describing it in the abstract. The table below is generated by scanning each procedure and rule for what it actually references.
Generated by scanning each procedure and rule for the knowledge documents, scripts, schema families and specialist agents it actually names.
| | Source | Knowledge it routes to | Scripts | Schema | Consults
| configure-product
procedure | abacus-engine-referenceboundariesconfiguration-ordermaking-a-product-issuablerating-tablesshared-vocabularytesting-a-product | abacuspcwrite | PR_*PPA_*LB_*SRD_* | backendfrontend
| explore-schema
procedure | — | pcmcp | — | backendfrontend
| roundtrip-test
procedure | discovering-a-productproduct-model | diff_modelexport_product | — | —
| evidence
rule | — | — | — | backend
| knowledge-upkeep
rule | — | — | — | db
| no-local-db-copies
rule | — | export_product | — | —
| reuse-before-create
rule | shared-vocabulary | — | — | frontend
| unknown-contracts
rule | writing-configuration | — | — | backendfrontenddb
Two patterns are worth reading off it. The main procedure is the hub — it reaches into most of the library, both write paths and all three specialists — while the other two procedures are deliberately narrow. And several rules point outward too: the one about unknown contracts names the specialists, because its whole content is "ask the team that owns the code rather than probing a write contract by trial".
The specialists, and the question each one settles
Two are created inside the workspace for a single task and deliberately denied context. Three are separate agents that own other systems; consulting them is the intended route rather than an escalation, because an answer read out of the owning team's code beats an inference drawn from this one.
tariff-analyst
Sub-agent · reads pricing documents
Turns a tariff into structure — layers, rate grids, base premiums, loadings and discounts with their bases and order — in business terms. Given no knowledge of the database on purpose, so the pricing structure cannot be quietly bent to suit how it will be stored.
grader
Sub-agent · scores the result
Compares a configured product against a reference one and scores it, seeing two artefacts and nothing about how either was produced. The independence is the point: an agent grading its own work grades the reasoning it remembers, not the output it produced.
backend
Consulted · owns the core services
Settles whether an existing sales process can be reused or a new one must be deployed, whether its task chain fits this product's shape, and whether a pricing rule can be expressed as a rate grid at all or needs code. Each of those answers decides whether the rest of the work can land.
frontend
Consulted · owns the web channel
Settles whether an object type is renderable and whether the wizard needs a component that does not exist — and drives the live quotation journey in a browser, which is the only thing that turns a configuration from a hypothesis into a product.
db
Consulted · schema-aware data access
Verifies the data behind a suspected defect against live records, returning structured evidence rather than an opinion. Read-only by default.
roundtrip-test
Procedure · measures the agent itself
Not a specialist but the way they are held to account: export a product that already works, hand the agent the same source documents, and diff what it produces against the original — scored by the grader. It answers whether this agent is good enough, and catches regressions when the knowledge changes.
Testing, and what each kind of proof is worth
A configuration that has not produced a premium is a hypothesis. The inner loop that turns it into evidence is a pricing call that computes and stores nothing — so it is safe to run as often as you like: change the configuration, request a price, read the breakdown, change it again. No interface, no writes, repeatable.
Two things about that loop are counter-intuitive enough to be worth stating. It ignores the cover selection you give it and prices every cover the product has, so the totals describe the whole catalogue rather than the case you asked about — reconciliation has to be per cover, which is the stricter check anyway. And the factor set must be built from what the product actually declares, queried rather than remembered, because the engine needs every factor the grids reference and not merely the ones belonging to the covers in question.
The failure messages are not equally honest — they are read literally
| | What comes back | What it actually means
| 400 · missing features | The clean case. It lists exactly what to add.
| 400 · missing factor, short rate | The same thing arriving one factor at a time, from a different path.
| 500 · internal error | Also a missing factor — a bare lookup failure surfacing through the catch-all. It names one and stops. A 500 here is usually not a platform fault.
| 400 · no configured premium | No deployed rating version resolves for the date you asked about.
| 200 · with no rates | The dangerous one. The platform logs a warning and produces a silently zero premium; it is treated here as a hard failure.
A zero premium has two opposite causes, and establishing which comes before any pricing investigation: either the engine priced zero, or it was never invoked at all. The engine writes a log row per pricing call — no rows means the fault is upstream, in covers, offers, dates or a selection that never reached it, and examining rates or grids in that state wastes the cycle. It is one query, and it decides which half of the system to look at.
Three levels, run in order — and the standing instruction is to say which one you actually reached
| | Level | Establishes | Cost
| Structural | The rows are there. Proves nothing about behaviour. | cheap
| Numeric | A premium comes back and reconciles against the source document. Where most real defects surface — a rate out by a factor, a discount applied twice, a cap that never binds. | cheap · run constantly
| Operational | The product works for a person: visible to the right role, covers selectable, dependent dropdowns populated, an offer produced. Only the interface shows this. | slow · before declaring done
A price tells you what it was asked. It cannot tell you whether an operator can reach the product, whether the covers are selectable in the right combinations, whether a required dropdown is empty and blocking, or whether the packages make sense — questions the rating engine never sees. "Structurally verified, priced correctly, not yet exercised in the interface" is treated here as an honest and useful result rather than an incomplete one.
The standing rules
Eight constraints applying to every task, each written against a specific way the work goes wrong
| | Rule | The constraint
| evidence | Read the thing before deciding about it. A column's meaning lives in its consumer, not in a guide — if you are about to say a field probably controls something, go and read where it is read.
| reuse-before-create | Object, cover, factor and message codes are platform-wide vocabulary. Check whether the code exists before creating it; reusing a code is not the same as inheriting the row behind it.
| unknown-contracts | Never probe a write contract by trial. It looks like progress — each attempt returns a slightly different error — and it is the slowest available route. Ask the agent that owns the code.
| missing-information | Never invent. Not a cover name, a limit, a lookup code, a rate, or a translation. Knowing what you do not know is part of the job rather than a failure of it.
| writes | Plan, show, then apply. Every write goes model → plan → operator approval → apply, and the tooling defaults to a dry run so an accidental execution costs nothing.
| verification | Rows landing in tables is not evidence the product works. Three levels, in order, and never imply the one above the rung you actually reached.
| no-local-db-copies | The database is the source of truth. Anything written to disk holding its rows becomes a second truth and begins diverging immediately.
| knowledge-upkeep | The knowledge base is priors, not law. When a guide turns out to be wrong, saying so and fixing it is part of the task.
Why stage four exists
"I compared the live configuration against my own model, and the model had been written to match what I built. Both reported five packages. A check that agrees with itself cannot detect an absent step."
— the agent's post-mortem on its own failed handover
A product was handed over as complete, its pricing reconciled to the cent, and it could not produce a quote. The packaging layer that decides what a customer is offered had never been wired, and the check that should have caught it measured the work against the plan that produced it. The remedy is procedural rather than advisory, which is why it is a gate in the sequence above and not a paragraph of advice: nothing is reported complete until it has been reconciled against a product already selling, with every divergence resolved or recorded as a deliberate omission.
Two traps in the library have the same signature — silent failure that looks like success. A tariff's future in-force date placed on the covers instead of the rating version leaves every cover not-yet-valid: the quote fails finding no covers while the rating side looks perfect. And an idempotence guard that matches too loosely will report a write as already done and change nothing at all. Both were found the same way, by comparing against something that works.
How it revises its own knowledge
Every one of those twenty documents was written by a run that had seen less than the current one is seeing. So the library is explicitly held as prior, not law, and correcting it is part of finishing a job rather than a liberty being taken — a run that works around a wrong guide and says nothing has done half the work, because the next run pays the same cost again.
When two sources disagree there is a stated order of belief: the running system first — a query just run, a response just received, a page just loaded — then the code that consumes the value, and only then the knowledge base, including anything the agent wrote itself last time. A guide that contradicts a fresh observation is wrong until proven otherwise, and talking yourself out of the observation is precisely the error the file will keep causing.
Four kinds of wrong, deliberately treated differently
| | What was found | How it is corrected
| Wrong
never was true | Replace it and state the correct thing plainly.
| Stale
true once | Replace it, and prefer recording a method over the new value — so it cannot go stale the same way twice.
| Ambiguous
reads two ways | Make the ambiguity explicit and say which reading was taken and why. Never silently resolve it.
| Environment-specific
true here | Say where it holds and how to check it elsewhere, rather than stating it as universal.
One rule governs the mechanics of the edit: fix the claim where it is made. Appending a correction and leaving the original standing produces a file that is worse than before — a reader who hits the wrong statement first stops there, and the document now lends authority to a trap. Every place the claim appears, headings and summary lines included, changes together.
A guide, not a diary — the file is read forever, long after the run that wrote it is forgotten
| | Never written into a document | Written instead
| "Verified on the 27th: the column is X" | "The column is X"
| "Observed on dev: 1,807 rows are null" | "It is null in practice and cannot hold a list"
| "Corrected after a failed run" | The corrected statement, alone
| A count, a rate, a live identifier | The query that regenerates it
Where a claim genuinely needs checking, the document says how to check it, never when it was checked — an instruction to confirm against the live schema stays useful forever; a date never does. The evidence for a change belongs in the run's report instead: the report is read once, by someone deciding whether to trust the edit, and the guide is read indefinitely by agents deciding what to do. And where the agent cannot establish which version is right, it is told not to overwrite on weak evidence — recording what it observed, what that contradicts, and what would settle it, because an honest open question tells the next run where to look.
The tooling is held to the same standard, because scripts are knowledge that executes. A guard that reports success while writing nothing, an export that cannot round-trip its own writes, a helper that handles one column type and not another — these are defects to repair at the cause, not conditions to work around at the call site. That clause has already been exercised: a silently-skipping write guard was found and generalised during this product's build, which mattered more than the product did.
Record the pattern, never the incident that taught it to you.
Memory works alongside the documents and to the same standard. Relevant entries are surfaced at the start of each turn as a short index of titles, drawn against the whole recent conversation rather than the last sentence, and expanded only when one looks worth reading. The agent queries it before investigating anything with a plausibly known answer — a connection, an endpoint, a past failure — and writes back whatever would save the next run a loop.
The distinction that keeps it useful: "a dependent dropdown renders empty when its label column is null" is worth keeping. "Product 9950 had blank dropdowns on Thursday" is not, and will actively mislead once 9950 no longer exists. The same discipline that keeps a document durable keeps a memory durable — the shape, never the story.
The division of labour between the two is deliberate. Memory accumulates without curation across every agent in the estate and is strong at has this been seen before. The knowledge library is curated, ordered and routed, and is what carries correct procedure. Neither substitutes for the other, and both are edited by the runs that discover they were wrong.
Complete contents
Generated from the workspace. Procedures are the methods it follows, sub-agents the specialists it instantiates, operating rules the standing constraints, knowledge its domain and platform reference, and source documents the material it was supplied for this product.
Procedures
3 items
Entry points. One covers any configuration task; the others cover investigating undocumented platform behaviour and measuring the agent's own accuracy against a known-good product.
configure-productThe entry point for product configuration work — configuring a new product from its documents, updating an existing one, or changing a live one. Covers the whole path: model the sources, check what already exists, apply IPAL / rating / offers / routing, verify numerically, and prove it in the UI. Use for any request that ends in "and then it should work".3,021 words
Configuring a product
This is the one skill for the job. Three shapes of request come in, and they share most of a path:
| | Request | What changes
| New product — documents arrive, nothing exists | everything below, in order
| Update — a new tariff, a new cover, a changed rule on an existing product | usually a new rating version; the catalogue side often untouched
| Change — fix something wrong on a live product | the smallest correct edit, and the blast radius matters more than the edit
Read the request for which one it is before planning. The commonest mistake is treating an update as a build: creating a second product, or rewriting configuration that was already right.
Everything here is guidance built from real runs. It has been wrong before. Where the database and this file disagree, believe the database and fix the file (rules/knowledge-upkeep.md).
Before anything: three questions that decide whether the work can land
Cheap to answer, and each one has stranded a finished product when answered late.
1 · Do the codes exist? Cover and object codes are foreign keys into closed lookups (shared-vocabulary.md). A code the document invents may not be creatable. Check first, not after building.
2 · Is there a deployed process that fits? A product whose shape has no deployed, product-code- parameterised Camunda process cannot be finished as configuration, however correct everything else is. Establish it from the running system (making-a-product-issuable.md). If the answer is no, that is a deploy boundary and a legitimate result — say so with the evidence, do not force a mismatched process (boundaries.md).
3 · What does the environment expect? Company and country codes, maintained languages, the currency the branch runs in. Take them from what live deployed products there use.
Stage 1 · Model the sources
No database. Read every document and build the semantic model — objects, covers, definitions, loadings and discounts, factors, the tariff, and the requirements that are not in the tariff.
Delegate the tariff to the tariff-analyst subagent. It has no database access on purpose: what the tariff says must not be coloured by what other products happen to look like. This is the one stage where isolation earns its keep.
Reconcile every count both ways, with evidence. Miscounting is the known failure of this work and it is invisible until someone counts. A mismatch is a stop, not a warning.
Record what the documents do not say. A rule stated without its content — a dependency with no pairs, a bundle rate with no split, a discount with no qualifying question — is a gap. Write it down with the reading you took and why. Do not silently resolve it (rules/missing-information.md).
Stage 2 · Reconcile against what exists
Reuse the code, scope the values (shared-vocabulary.md). Check near-duplicates before minting anything, and reject a candidate for a reason — wrong semantics beats matching name.
⚠ Reusing a code does not mean inheriting a row. A shared factor still needs this product's own PR_PRICING_FACTORS row with its own allowed values. A product with no row for a shared factor does not inherit it — it simply does not have it, and the field renders empty.
Stage 3 · Apply, in dependency order
configuration-order.md has the order. pcwrite writes every row; the gateway is used only for the handful of operations that are not row writes.
The layers, and the thing each one is for:
- IPAL — the catalogue: product, covers, definitions, LDs, documents, annexes.
- Factors — what the operator is asked. Including the object's own attributes: the vehicle / person / property step renders from factors describing the object, so without them that step is empty. Derive the set from live products on the same object code.
- Rating — the tariff (abacus-engine-reference.md, rating-tables.md).
- Offers — not optional. Without them the quote returns "no offers could be found".
- Routing — the route row that makes it visible.
Every plan step must be checkable, and must be checked. Give each step a stable id and a one-line assertion that decides whether it happened — a count, or a row that must exist. When the apply finishes, run those assertions and reconcile them against the plan. A step can sit in a plan, never execute, and leave nothing behind that looks wrong: the log records only what ran, so an unrun step is invisible in it. A plan you cannot reconcile against what was written is a description, not a plan.
The same applies inside the write tooling. A guarded insert that affects zero rows either found the row already present or was silently skipped by too broad a guard, and those are indistinguishable from the call site. Surface the zero — a step recorded as done having written nothing is exactly how an absent row survives a build and a verification pass.
Stage 4 · The donor diff — a gate, not a review
A product may not be reported complete until it has been diffed table-by-table against a working donor of the same object type, and every difference has been either configured or written into MISSING.md with a reason.
This gate exists because the obvious check does not work. Diffing the live configuration against your own model proves nothing: the model was written to describe what you built, so an entity you never thought of is absent from both and the two agree perfectly. A self-consistent check cannot find an absent step. Only a product that already works can tell you what you have not done.
Pick the donor by object code and shape, not by name — something live, that a person can actually sell. Then compare counts per table:
| | | Table | What a zero means
| catalogue | PR_BASIC_COVERS | no covers
| | PR_BASIC_COVER_NAMES | covers render blank
| | PR_BASIC_COVER_DEF | no limits or deductibles to select
| | PR_BASIC_COVER_RISKS | cover covers nothing
| | PR_BASIC_COVER_EVENTS | no claim events
| | PR_BASIC_LD | the catalogue side of loadings/taxes is absent
| | PR_BASIC_PRODUCT_DOCUMENTS | no document types in the UI
| | PR_ANNEX_TYPES | no endorsements
| | PR_OPERATIONS | no underwriting/issuing operations
| factors | PR_PRICING_FACTORS | nothing is asked
| | PR_PRICING_FACTOR_VALUES | dropdowns empty
| | PR_PRICING_FACTOR_DEPENDENT | dependent dropdowns unfiltered or empty
| | PR_PRACING_FACTOR_DBFLD (sic) | answers have nowhere to be stored
| | SRD_INTEGR.ABC_CFG_PRICING_FACTORS | factor values never reach the engine
| offers | LB_OBJECT_NA | the object cannot be recognised
| | LB_OBJECT_NA_OBJECT_GROUP | the object's group cannot be derived
| | LB_OFFERS / LB_OFFER_COVERS | quote returns "no offers could be found"
| | LBRR_COVER_DEPENDENCY / _CONFLICTS / _LIMIT_RESTRICTIONS | rules unenforced
| rating | PPA_PRODUCTS / PPA_FACTORS / PPA_PRODUCT_VERSIONS | nothing prices
| | PPA_PRODUCT_COVERS / PPA_PRODUCT_LD / PPA_PRODUCT_FILES | covers or LDs unpriced
| visibility | SRD_SYS."Routes" | invisible
Extend the list when the estate does; treat it as a floor, never a ceiling.
⚠ The gate is "account for", not "copy". Some rows in a donor are bespoke to that product and copying them attaches its behaviour to yours. Operation rows are the clearest case: the class names they carry can be namespaced to the donor's product code, so a wholesale copy silently imports another product's premium adjustments and fixes. Read the value before copying it, copy only what is generic to the line of business, and record the rest as deliberately not copied — that closes the gate just as well as configuring it, and honestly.
Counts are the first pass, not the gate. A row can be present and still wrong, and for shared entities that is the common case. For every factor, cover or lookup the product shares with the donor, compare the ROW: its source column, whether it is visible, its datatype, its default, and the form of its values. A factor configured as a visible question where the donor has it hidden and sourced from a policy field is never collected, and the quote fails naming a factor that is plainly present in the table. Presence and shape are different checks; the gate needs both.
Two asymmetries make this gate necessary rather than tidy:
- PPA_PRODUCT_COVERS has no foreign key to IPAL. A product can price perfectly while its catalogue side is missing or wrong. A correct tariff is not evidence of a sellable product.
- The offers path is resolved BEFORE rating is reached. A quote that fails in offer resolution never calls the rating engine at all, so a perfect premium from the rating API says nothing about whether a quote can be produced.
Stage 5 · Verify numerically
Compute the expected premium from the documents, never from the engine or the database, and compare (testing-a-product.md).
Design the cases to separate the rules. Under a SUM rule several loadings can net to the right total by cancellation — a configuration broken in four places can return the correct number. Vary one thing at a time, include the extremes, and cover each axis the tariff has.
⚠ Calling the rating API directly proves less than it appears to. You hand it a factor payload, so it never exercises the machinery that builds that payload from configuration — the object-group derivation, the offer resolution, the factor-to-field mapping. Every one of those sits upstream of the engine. A figure reconciled to the cent this way establishes that the grids and LDs are right, and nothing whatsoever about whether a person can obtain that figure.
Stage 6 · Prove it in the UI
A priced product is not a sellable one. Products price to the cent and remain unsellable for reasons the engine cannot see: empty dropdowns, missing object attributes, unresolvable offers, covers out of force, a factor whose value the backend parses as a different type. The engine never sees a dropdown.
Drive it, or delegate a browser run to frontend — it owns the app and its credentials.
Treat each new blocker as the next item of work, not as a result to report. Reaching a fresh error is progress; stopping at it is not. Keep going until a premium renders.
⚠ A browser run is slow and may exceed a delegation timeout. That is a stalled tool call, not evidence about the product. Say the run stalled; never convert it into "not reproducible" or "probably fine". Ask for a short partial answer and continue.
⚠ Distrust a convenient attribution. "That error is a platform bug, not your configuration" is the single most expensive thing to accept without checking, because it ends the investigation. Test it against the donor first: if the donor's value for that field differs from yours in type or vocabulary, the defect is yours.
Two gates decide whether a factor's value reaches the quote
A row in PR_PRICING_FACTORS makes a factor exist. It does not make its value arrive. Two frontend gates decide that, and a correct-looking row satisfies neither by itself:
- A form control must exist on the step being submitted. The payload is built from the keys present in the current step's form group. Step fields are bucketed by SALE_STAGE and gated on UI_FLAG = 'Y'; anything else is dropped, bar a small hardcoded force-include list. So a factor the rating version needs as an axis must be visible at quote stage unless its code is on that list.
- The source decides which array it travels in. A policy-level source buckets the value into policyValues; an object-level source (OBJ_CAR.<field>) buckets it into objectValues. A rating axis belonging to an object-scoped cover must arrive in objectValues — supplied in the policy array it still reads as missing.
⚠ Do not copy a donor's visibility. A donor can carry a factor hidden at quote stage and still work, because its own rating version does not demand that axis there. Visibility is only transferable together with the rating shape that justifies it — copying it blind removes the value from the payload entirely.
When the table and the wizard seem to disagree, read the list the wizard actually receives rather than inferring from the database. If it is a clean mirror of the table, that rules out drift and caching in one observation and makes the remaining explanation mechanical. If it differs, the difference is the finding — report it before acting on it.
The ladder — say which rung, and never imply the one above
| | Rung | What it proves | What it does NOT prove
| structural | rows exist and match a donor | that any of them is reachable
| priced | the grids and LDs compute the tariff correctly | that a quote can be produced at all
| reachable | the wizard opens and renders its steps | that it will complete
| quoted | a premium renders in the browser | that a policy can be issued
| issued | a policy exists | —
Say which rung you reached. Never conflate them, and never report a rung you inferred.
Updating and changing an existing product
A new tariff is a new version, not a new product. Open a version, configure it, compile, deploy. The catalogue side usually does not move.
Know what a version owns. A version carries its covers, LDs, grid files, rules, filter and effective date — so changing one version changes nothing about another (abacus-engine-reference.md).
Two dates, different meanings — the mistake is conflating them:
| | | Means | Comes from
| version EFFECTIVE_DATE | when this tariff applies | the tariff document
| product / cover VALID_FROM | when the thing exists in the catalogue | usually no document — decide and say so
Putting a tariff's future in-force date on the covers makes them not-yet-in-force, and the quote fails with no covers found.
Blast radius, before you edit. A rating file may be referenced by more than one cover or product; a message code is global; a lookup row is shared. Changing shared content changes it everywhere. To differ, add a scoped row — never edit a global one.
A rate change is not live until it is recompiled and redeployed. The engine reads the compiled artefact, not the grid you edited.
The tariff shapes you will meet
A tariff is a rating rule — the base-premium rule plus its tables. The dimension decides the arithmetic; get this wrong and the premium is wrong by a factor, not by a rounding error.
| | Shape | How it is configured
| percentage of a sum insured | dimension P, with the base naming what it is a percentage of
| absolute amount | dimension V
| per unit — per person, per seat, per object | dimension M, base names the count
| derived from another cover | base is a single BP(<cover>) term — and only one
| computed from several inputs | the base is an expression: arithmetic and functions over factors
| conditional | a grid with the condition as an axis, not a separate rule
| not expressible as a table | a filter — see below
⚠ A published rate that covers a bundle of covers does not tell you how it splits. If two covers cite one rate, the split is a gap in the document, not an arithmetic exercise.
Filters — where the rules a grid cannot hold
A grid is a pure function from factors to one number. A filter sees the assembled result across covers and can rewrite it. Real ones: derive a factor from another field, clamp an override, apply a cross-cover minimum premium and redistribute it, rebase a cover on another's written premium, recompute a loading as compounding inside an otherwise additive product.
So a policy minimum premium is a filter concern, not a column. PREMIUM_MIN has no reader in the rating path. A document that states a minimum premium needs a filter — and writing one is a code change, which is a boundary (boundaries.md).
Offers, and the one link to rating
The rating engine knows nothing about offers. The single bridge is the template code: an offer names a template, and a template is a complete parallel rating configuration. That is how one product carries several tariffs — channel, term, currency — each a version under its own template.
⚠ Two consequences worth holding:
- A quote prices every cover of the resolved version, not the ones selected. Selection is applied by the caller, so a cover configured but not in the offer still comes back.
- An unknown template code does not error. It falls back silently and prices against a different tariff. Verify the template you configured is the one being resolved.
Cover dependencies
The domain owners call this the hardest part of the system — "millions of combinations". A dependency needs one row per admissible pair, and the dependent factor keeps its own value list which the dependency selects from. A missing pair is an empty required dropdown that blocks the quote; the field is still rendered and still mandatory.
The reference from the factor carries a two-part form — the driver and the path its value is read from — not a bare code. A bare code stores happily and filters nothing.
When you finish
Report the level reached, the premium both ways with the arithmetic, what you could not verify, and every knowledge or tooling file you changed and why (rules/knowledge-upkeep.md).
A clean stop with a clear handover — "this needs X deployed, here is the evidence, Y owns it" — is a successful outcome. A green result that hides one of these is not.
explore-schemaWork out what a table, column, or configuration value actually does — from the live schema and from the backend and frontend code that consume it. Use when the knowledge base is silent, ambiguous, or contradicted by what you observe.276 words
Explore
For when you need to know what something does, not what a document says about it.
Order
1 · Memory. mcp__memory__recall for the specific thing — a column, an endpoint, a past failure. This bank has been fed by other agents on the same systems. A second costs nothing; rediscovering by probing costs a loop.
2 · The live schema. scripts/pcmcp.py against the catalog: which columns exist, their types and widths, constraints, and what is actually populated. A column that exists and is NULL everywhere behaves very differently from one that is used.
3 · The consumers. This is the step the previous implementation could not take, and it is the point of having source access.
- serdica-backend — grep for the column or table name. Where a value is read tells you what it does; a column with no reader is configuration nobody consumes, which is itself a finding.
- serdica-ui — the Angular form generator is where pricing-factor dependency, visibility and validation behaviour lives. "The dropdown is empty" is explained there, not in the schema.
4 · Compare with a working product. The fastest way to understand a configuration value is to see what a correctly-behaving product has in it — a live product on dev that already sells is the best reference available.
Then write it down
If what you found is not in knowledge/, or contradicts it, fix the file — .claude/rules/knowledge-upkeep.md. Describe the shape, name the consumer, and date it. Also mcp__memory__retain anything that would save another agent the same loop.
A finding you did not record is a finding someone else will have to make again.
roundtrip-testThe acceptance test — export an already-configured product, hand its source documents to the agent, and diff the result against the original, scored by an independent grader. Use to measure whether this agent is actually good enough, and to catch regressions.478 words
Round-trip test
The method the team agreed on for deciding whether this works:
Vladimir: "Ние да му кажем 'въведи тази тарифа за този продукт' — това е като тест — и после да видим дали правилно е въвел, в сравнение с [съществуващия]." Veselin: "Дори можем да минем през [deployment] обектите … от тях може да възпроизведе всичко. Не се губи информация."
Build the golden set
For each reference product:
- scripts/export_product.py --product <code> --env <env> → tests/golden/<code>/reference.json
- Collect its actual source documents into tests/golden/<code>/sources/ — the template, tariff, terms, requirements. Without the real sources the test measures nothing.
- Record what it is and why it was chosen: class, notable features, known quirks.
Pick products that differ in shape, not just in name — a motor, a property with limit restrictions, a multi-object travel, one with a non-trivial rating grid. Five varied products beat twenty similar ones.
If a real product cannot be exported into the model shape without losing meaning, that is the first finding: the spec is wrong. Fix knowledge/product-model.md before continuing.
⚠ Confirm the document and the database product are the same configuration. Observed during development: a product code that existed in both a template and the database turned out to be two unrelated configurations — different names, different cover codes, same number. Pairing them would have measured drift between two unrelated things and read as the agent being wrong.
For each golden entry, check that the cover codes in the document actually appear in the export before trusting the pair. A shared product number is not evidence they are the same product.
Run
For each product, in a clean working directory, with only the sources:
- configure-product (stage 1, model only) → candidate model
- diff_model.py reference.json candidate.json
- grader sub-agent on the two artefacts — it sees neither the documents nor how the candidate was produced. That independence is the point.
Optionally continue through plan and apply into a scratch environment for a full end-to-end run — but the model-level comparison already catches the failures that matter most.
Score
Per product and in aggregate: completeness, counts, correctness, excess, semantics — with the fatal and material differences listed. A number without the list is not a result.
Watch the counts across products. Systematically miscounting covers or tariff layers is the known failure mode; it shows up here first as a pattern rather than a one-off.
Use the results
- A recurring miss is a knowledge or skill gap → fix the guide, not the instance.
- A gap only visible in one class → knowledge/discovering-a-product.md.
- A miss caused by a genuinely ambiguous document → not a defect. It should have appeared in MISSING.md. If it did not, that is the defect.
Record each run's date, products, and scores. The trend is the argument.
Sub-agents
2 items
Instantiated per task inside the workspace, each denied context that would let it rationalise a shortcut.
graderIndependently scores a configured product against a reference (golden) product. Use for the round-trip test. Sees the two artefacts and nothing about how either was produced.255 words
You compare two product models — a reference and a candidate — and score the candidate.
You are deliberately given no information about how the candidate was produced, what it found hard, or what it says about itself. You see two artefacts. That independence is the point of you existing: an agent grading its own work grades the reasoning it remembers, not the output it produced.
Score these separately
| | Dimension | Question
| Completeness | Is every entity in the reference present in the candidate?
| Counts | Do the counts match — objects, covers, definitions, LDs, factors, layers, offers?
| Correctness | Where both have an entity, do the values agree?
| Excess | Does the candidate contain things the reference does not?
| Semantics | Are differences cosmetic (naming, ordering, surrogate IDs) or real (a different limit, a missing dependency)?
Report per dimension, with the specific differences. Surrogate IDs and row ordering are not differences. A different cover type, a missing dependency row, or a rate that differs is.
Judge severity
- Fatal — the product would not work or would price wrongly
- Material — a real behavioural difference an operator or customer would notice
- Cosmetic — naming, ordering, formatting
An overall verdict is only useful with the fatal and material lists attached. Give the number and the list, not the number alone.
Do not be generous. A candidate that is 90% right with one wrong rate is not a pass.
tariff-analystReads tariff files and turns them into the tariff section of the product model — layers, rate grids, base premiums, loadings/discounts with their bases and order. Use when the task involves reading a tariff, a rate table, or a pricing document. Knows nothing about the database on purpose.388 words
You read tariffs and produce structure. That is all you do.
You deliberately know nothing about the database, the schema, or how any of this gets stored. Someone else handles that. Your output is the tariff section of product-model.json plus any loadings/discounts you found — in business terms, from the document.
The rationale, from the team: "Това умение на ABACUS е напълно отделен агент — няма нужда да знае нищо [за базата]. Базата е просто една реализация на таксономията." If you find yourself wanting to know a table name, you have drifted out of your job.
What you produce
For each cover the tariff prices:
- the shape: a flat rate, a conditional rate, or a grid
- for a grid: the row factor, the column factor, the value labels on each axis, and the rates
- the base: what the rate is a percentage of (sum insured, base premium, a fixed value)
- min and cap, if stated
For each loading, discount, tax or fee:
- the sign (does it increase or decrease the premium)
- the dimension: a percentage, a fixed value, or a relativity multiplier
- what it applies to: the base premium or the insured value
- the order it applies in, and whether the tariff says the adjustments sum or multiply — these give different answers and the document usually implies one
- the rule, quoted in the document's own words
Cite the file, sheet, and range for everything.
Count the layers, and say so
The known failure is miscounting: layers, covers, discounts. Before you finish, state explicitly how many the document contains and how many you produced, with the evidence for the count. If they do not match, that is your finding, not something to smooth over.
Non-linear rules
Some rules cannot be a grid — typically one that would need an enormous table or a tree of conditions. Do not flatten one into an approximate grid. Describe the rule in prose, mark it as needing a filter, and hand it on.
When the tariff is ambiguous
Multiple tariff files with no legend saying which cover each belongs to is a blocking gap, not a puzzle to solve by inference. Say which files you have, what you could and could not attribute, and stop there.
Operating rules
8 items
Standing constraints on every task — write discipline, evidence standards, and conduct where the platform is undocumented.
evidenceIf you are about to say "this field probably controls X" — go read where X is read. serdica-backend321 words
Evidence
Read the thing before deciding about it. A column's meaning is in its consumer, not in a guide. If you are about to say "this field probably controls X" — go read where X is read. serdica-backend and serdica-ui are checked out for exactly this.
Discover, don't assume. Find where data lives in the live schema. Never hardcode a table or column from memory, and never assume something is populated because a document says it should be.
Corroborate a negative. "This product has no offers configured" from one query is a hypothesis. Convenient negatives — it doesn't exist, there's no such column, that table is empty — are the ones most often wrong. Check a second way before you report one.
Say where a number came from. Any count, ID, code, or rate you report should be traceable to a query or a file and line. If you cannot trace it, do not report it.
Distrust a sub-agent's convenient answer for the same reason you distrust your own. If a sub-agent reports that something is missing or inaccessible and that contradicts what you already established, tell it how to get the thing and ask again.
A defect report requires a working counterexample to have been checked and named. Before reporting anything as a platform, backend or frontend defect, establish whether products that work today succeed through the same path. If they do, the difference is in your configuration, not in their code — find it. Name the products you checked and what they carry, in the report.
Reading the source and finding surprising behaviour is not enough: long-standing intentional design reads exactly like a defect when you have only ever seen it fail. The counterexample is what separates the two, and skipping it sends another team to chase your own misconfiguration.
When a guide is wrong, that is a finding. Say so, and fix the guide — see knowledge-upkeep.md.
knowledge-upkeepThe knowledge base is a set of priors, not law. Things change — a new column, a new rule, a1,133 words
Keeping the knowledge current
The knowledge base is a set of priors, not law. Things change — a new column, a new rule, a product shape nobody wrote down, a service that moved. Every file here was written by someone who had seen less than you are seeing right now.
Correcting it is part of the job, not a liberty you are taking. A run that works around a wrong guide and says nothing has done half the work: the next run pays the same cost again.
What outranks what
When two sources disagree, believe them in this order:
- The running system — a query you just ran, a response you just got, a page you just loaded.
- The source code — what actually consumes the value.
- This knowledge base — including anything you wrote yourself last time.
A guide that contradicts something you have just observed is wrong until proven otherwise. Do not talk yourself out of an observation because a file disagrees with it; that is exactly the error the file will keep causing.
Fix the claim where it is made
⚠ Do not append a correction and leave the original standing. A file that states the wrong thing in one place and the right thing three sections later is worse than before — a reader who hits the first one stops there, and now the file has authority behind a trap.
Find every place the wrong claim appears — including headings, tables and summary lines — and change them all. Then check the file still reads as one coherent piece, not a paragraph with a footnote arguing against it.
Four kinds of wrong, four different treatments
| | What you found | What to do
| Wrong — the claim was never true | Replace it. State the correct thing plainly.
| Stale — true once, not now | Replace it, and prefer a method over the new value, so it cannot go stale the same way.
| Ambiguous — reads two ways, you had to pick | Make the ambiguity explicit and say which reading you took and why. Do not silently resolve it.
| Environment-specific — true here, unknown elsewhere | Say which environment, and how to check it, rather than stating it as universal.
Write it as though it were always true
A knowledge file is a guide, not a diary. It is read by every future run, long after the run that wrote it is forgotten, and anything that dates it either misleads or distracts.
Never write into a knowledge file:
- a date — "verified 2026-08-27", "audited last week", "as of today"
- a run, a session, or a product you built while learning the fact
- who or what established it — an environment name, a colleague, a previous agent, another repository
- an observation framed as history — "this was corrected after…", "originally we thought…"
A dated claim is the worst of both worlds: it reads as authoritative forever, while carrying no evidence a reader can act on. "Verified 2026-08-27" cannot tell anyone whether the schema moved yesterday.
Instead, make the claim carry its own durability:
| | Instead of | Write
| "Verified live 2026-08-27: the column is X" | "The column is X"
| "Observed on dev: 1 807 rows are null" | "It is null in practice and cannot hold a list"
| "Corrected after a failed run" | the corrected statement, alone
| a count, a rate, a live identifier | the query that regenerates it
Where a claim genuinely needs checking, say how to check it, never when it was checked: "confirm with ALL_TAB_COLUMNS before relying on this" stays useful forever; a date never does.
Uncertainty still gets expressed — in the wording of the claim, where it stays true. "X is usually Y; confirm for the object you are configuring" is honest. "X was Y on Tuesday" is archaeology.
The evidence belongs in your report, not in the file. Your report is read once, by someone deciding whether to trust the change; the file is read forever, by agents deciding what to do. Put the query, the error and the file:line in the report. Put the settled fact in the guide.
The same applies to the tooling
Scripts are knowledge that executes. If one is wrong, incomplete, or lies to you — fix it, with the same discipline: fix the cause rather than working around it at the call site, and say what you changed and why. A guard that reports success while writing nothing, an export that cannot round-trip its own writes, a helper that handles one column type and not another: all are defects to repair, not conditions to live with.
When you are not sure
If you cannot establish which version is right, do not overwrite a claim on weak evidence — you would be replacing one unverified statement with another. Record what you observed, what it appears to contradict, and what would settle it. An honest open question is worth more than a confident guess, and it tells the next run where to look.
Say what you changed
List every knowledge or tooling change in your final report — file, what it said, what it says now, and what established the change. This is the place for that evidence, and the only place: the report is reviewable, the file is not. These files are shared, and a silent edit is a change nobody can review.
What not to touch
- A decision the operator has stated. If evidence now contradicts it, raise it — do not quietly reverse it.
- Values that belong in the database. Row counts, current rates, live identifiers: query them, never bake them in. See no-local-db-copies.md.
- Credentials. Connection and endpoint names, yes. Secrets live in the environment.
And keep the incident out of it
Describe the shape, not the story. "AMNT_LIST is VARCHAR2(1) and cannot hold a list; the enumeration lives in the factor's values" is durable. "This was wrong and broke a run today" is not — it dates the file, tells the reader nothing actionable, and reads as an apology rather than a fact. Fix the structure so the next reader never needs the story.
Also mcp__memory__retain anything non-obvious that would save the next run a loop — and write the memory to the same standard. A memory is a guide too: record the pattern, never the incident that taught it to you. "A dependent dropdown renders empty when its label column is null" is worth keeping; "product 9950 had blank dropdowns on Thursday" is not, and will mislead once 9950 is gone.
missing-informationKnowing what you do not know is part of the job, not a failure of it.359 words
Missing information
Knowing what you do not know is part of the job, not a failure of it.
Never invent. Not a cover name, not a limit, not a lookup code, not a rate, not a translation of a term you are unsure about. A configuration built on a plausible guess is worse than an incomplete one, because it looks finished.
Missing information is a deliverable. It goes in the model's unresolved list and comes out as MISSING.md, structured so the operator can act on it:
- what is missing (the specific field, for the specific entity)
- why it is needed — what cannot be configured without it
- where it would normally come from — the tariff, the requirements document, a lookup table, a business decision
- blocking or not — can the rest proceed without it?
"Not in the source documents" is a claim about the documents — go and read them. It is not an inference you may draw from the database. If a value is absent from the schema, that says the platform does not hold it; it says nothing about whether the operator gave it to you. Re-open the document and look before you escalate. Escalating a question the sources already answer costs a round trip and spends the operator's attention on something you had in hand.
The same applies in reverse: a sheet you skimmed once for one purpose may answer a question you only form later. Re-read it against the new question rather than against your memory of it.
Distinguish three cases, they need different responses:
- Absent from the sources → ask; name the document you expected it in.
- Present but ambiguous → propose a reading with your reasoning and ask for confirmation.
- A business decision → mandatory or optional, which segment, which roles see it. Not yours to make. Present the options and their consequences.
Auto-fill only what is genuinely derivable, and say that you did: a missing bg/en translation of a term that appears elsewhere in the same document, a lookup code that follows an unambiguous local convention. Anything requiring a choice is not derivable.
no-local-db-copiesThe database is the source of truth. Anything you write to disk that holds its rows becomes a295 words
No local copies of database contents
The database is the source of truth. Anything you write to disk that holds its rows becomes a second truth, and it starts diverging the moment it is written.
This is not hypothetical. A modelling run once worked from cached exports and reported that a factor's datatype was IV and that a claims-history factor had values NONE/ONE/TWO_PLUS. Live data said NUMBER, and boolean Y/N. A stale copy does not fail loudly — it produces confident, specific, wrong answers that then have to be caught downstream.
So:
- Do not save query results. The query log records the statement, environment and row count — what was asked, never what came back. If you need the rows again, run the query again; it costs a second.
- Do not keep exports in the workspace. export_product.py prints by default and refuses to write into products/ or knowledge/. If you genuinely need a snapshot to diff against, write it somewhere temporary outside the workspace and delete it when the comparison is done.
- Do not put database values in guidance. Knowledge files carry structure, rules and the query that answers the question — never counts, amounts or row contents. Those drift, and a reader cannot tell a stale number from a current one.
- Cite live, not cached. When you state a fact about an environment, it should come from a query you just ran. If you could not reach the database, say so and stop — do not fall back to something on disk and present it as current.
The exception is a document the operator gave you — a product template, a tariff file. That is an input, not a copy of the database, and it belongs with the product.
reuse-before-createObject codes, cover codes, factor codes and message codes are platform-wide vocabulary, not names511 words
Reuse before create
Object codes, cover codes, factor codes and message codes are platform-wide vocabulary, not names local to the product you are configuring. Before creating any of them, check whether the code already exists — the queries are in knowledge/shared-vocabulary.md.
If it exists, reuse it, and read how existing products configure it before deviating.
Minting a near-duplicate of an established code is the failure this prevents. It produces a product that configures without error, prices correctly, and then misbehaves — the UI component, the rating grid or the downstream mapping was keyed to the original code, and yours is not it.
Reuse the code; scope the value
Reusing a code is free. Editing a shared row is not. Rows with a null PRODUCT_CODE are global — changing one to suit one product changes it for every product using it.
To make a shared factor behave differently for your product, add a product-scoped row. Never edit the global one, and never delete it.
The same applies to anything reached by pointer rather than copied: a DESCR_LINK message, another product's rating file. Sharing a code is usually intended. Sharing another product's content is usually an accident, and edits to it land on both.
Scoping a value is not free either — check what consumes it
"Reuse the code, scope the value" is right, and it has a limit worth stating: a factor's values are a vocabulary that something downstream may already understand. Before replacing a shared factor's values with a document's own, find out who reads them. Two ways this bites, both silent:
- A value that must match another table. Some factors feed a lookup elsewhere — the offers engine derives an object group from one and matches it against shared rows. Scope that factor's values to a local vocabulary and the match simply fails; nothing errors, there is just no result.
- A value that is parsed as a type. A value can look like a free code and be deserialized downstream as a number, a date or an enum. A field meaning "how many instalments" holds 1, 2, 12 and is read as an integer; putting ONCE there configures cleanly, prices correctly, and crashes when a policy is written.
So: look at the existing values before you replace them. Their form is evidence. If a shared factor's live values are all integers, or all match rows in another table, that is a contract — the names are not arbitrary and neither is the datatype. Where the document's vocabulary genuinely differs, map to the existing form rather than overwriting it, and record the mapping.
When a new code is right
When the thing is genuinely specific to this product and nothing equivalent exists. That is common for product-specific factors and normal — the rule is to check first, not to avoid creating.
Object codes are the exception: the set is effectively closed and coupled to frontend components, so a new one is a frontend change, not a configuration change. Confirm before assuming you can add one.
unknown-contractsThe single most expensive mistake available here is probing a write contract by trial. It looks like318 words
When you do not know a contract, ask the peer who owns the code
The single most expensive mistake available here is probing a write contract by trial. It looks like progress — each attempt returns a slightly different error — and it is the slowest possible way to read a C# class.
The rule. After two failed attempts at the same call, stop. Do not vary the payload a third time. Either read the contract yourself in the source, or delegate to the peer that owns it:
- backend — DTO input models, gateway command shapes, AutoMapper renames, the workflow engine, premium and operations logic
- frontend — how configuration reaches the screen, route semantics, wizard steps, what an operator actually sees when something is wrong
- db — schema, constraints, what data actually exists
Give them what you already established, and ask a narrow question. "What is the literal JSON body for a create on this endpoint, and why does the array form time out" gets a precise answer from one file. "Help me with writes" gets a sweep.
Why this rule exists. Building the first product through these gates took roughly fifteen failed attempts at the payload shape, spread over an hour, ending in a wrong conclusion about a collection property. The owning peer found the answer — a primary key exposed as id and renamed by AutoMapper — in a single file, in minutes, because it could read the class rather than infer it from error text.
The tell that you are in this failure mode: you are changing the shape of a request rather than its content, or you are reasoning from an error message about what the server "must" want. Error messages describe symptoms. The class describes the contract.
knowledge/writing-configuration.md records what that cost bought — read it before writing anything, and add to it when you learn the next one.
verificationRows landing in tables is not evidence the product works.183 words
Verification
Rows landing in tables is not evidence the product works.
Three levels, in order:
- Structural — re-export the live configuration and diff it against your model. Every difference is either explained or fixed. An unexplained difference is a defect, including the ones that look harmless.
- Behavioural — exercise it. Open the product in the UI, build a quote, see the covers appear, watch a dependent factor reload when its driver changes, produce an offer. If a limit was supposed to be selectable, select it.
- Numeric — where a tariff is involved, compute a premium for a known case and reconcile it against the tariff document. One reconciled figure is worth more than a green apply log.
Report what you actually did. "Verified" means you ran it and saw the output. If you only checked the rows exist, say that — it is a real but partial result. Never describe a check you did not run, and never present expected output as observed output.
When you cannot verify, say which level you reached and what blocked the next one.
writesscripts default to dry-run; that default exists so an accidental run costs nothing.358 words
Writes
Plan, show, then apply. Every write goes: model → PLAN.md → operator OK → apply. The gate scripts default to dry-run; that default exists so an accidental run costs nothing.
Prefer copy-and-adjust. Cloning a similar product's structure and changing what differs is faster and inherits decisions that were already reviewed. Building from nothing is the fallback, not the default.
Dependency order matters. Lookup codes before the rows that reference them; product before covers; covers before definitions, risks, events; factors before values before dependencies. If an apply fails halfway, it must be resumable — do not re-run from the start and create duplicates.
Idempotence. Before creating, check whether it already exists. A second run of the same plan should change nothing.
Dev is the only environment. Every read and every write targets dev; nothing else is configured or reachable, on either path. If a task appears to need another one, stop and say so.
Deleting is a different act from writing. DELETE is permitted on this instance's writable tables — verify with DELETE ... WHERE 1 = 0 in mode="edit" rather than assuming either way. On a live product prefer UPDATE: a delete is unreviewable and may break something that references the row. On a test product being torn down, delete, and drive it from the product's own write log so the set removed is exactly the set written. Children before parents, one statement per table, stop at the first failure, and never remove a lookup, message or shared row you merely REUSED — check for other referrers first and say which ones you left.
A failed write is reported, not retried blindly. Note the exact error, work out why, adapt. Three identical retries is a loop, not persistence.
Log everything. Two logs exist under products/<product>/ and they are not the same thing: queries.jsonl is written automatically by the SQL transport and records every statement with its environment, role, mode and row count; apply-log.jsonl is the applying script's own record of what it intended and what came back. Between them they are how anyone reconstructs what happened — and how a teardown knows exactly what to remove.
Knowledge
20 items
Topic-sliced reference, loaded by filename rather than wholesale. Held as prior, not law: the running system and the consuming code outrank it.
READMETopic-sliced reference. Load the file you need, not the directory.383 words
Knowledge index
Topic-sliced reference. Load the file you need, not the directory.
All of this is a guide, not a law. Every file here is a prior distilled from previous runs and from the previous implementation. It can be stale, over-general, or wrong for the product in front of you. The live schema, the live code, and the user's document win. When a guide is wrong, say so and fix it — .claude/rules/knowledge-upkeep.md.
The domains
| | File | What it covers
| three-domains.md | The map: IPAL ↔ ABACUS/SRD_ANLT ↔ offers, and the rule that every factor source must resolve locally. Start here if you are new to a product.
| ipal-configuration.md | IPAL: products, objects, covers, definitions, risks, events, LDs, documents, annexes
| pricing-factors.md | PR_PRICING_FACTOR* — datatypes, levels, dependencies, what is client-side only
| shared-vocabulary.md | reuse codes, don't mint them — objects/covers/factors are platform-wide
| translations.md | every visible name — DESCR_LINK → SR_MESSAGES → per-language text
| rating-tables.md | creating a product's own rates — author a grid, bind it, compile, deploy
| abacus-engine-reference.md | the rating engine, from its source — BP_BASE grammar, P/V/M/R, the getRates pipeline, templates, filters, the grid format
| analytics-ppa.md | ABACUS / SRD_ANLT PPA_* — versions, rating tables, LD/tax, calculation
| offers-engine.md | LB_* / LBRR_* — offers, packages, cover dependencies, conflicts, limit restrictions
Doing the work
| | File |
| configuration-order.md | the order of work, the dependency chain, and the routing table
| product-model.md | the product-model.json spec
| writing-configuration.md | the three write paths, the id convention, and what each error actually means
| testing-a-product.md | how to prove a configuration works — the quote loop, the gateway mechanics, what each verification level actually proves
| input-contract.md | what each source document is authoritative for; the legend
| discovering-a-product.md | how to work out what a product is — from the document, then from the live estate
| schema-quirks.md | observed naming inconsistencies and filter syntax. Observations, not truth — introspect.
| environments.md | connections, endpoints, business lines, what is safe where
| boundaries.md | what is not this agent's job
abacus-engine-referencedf = dffull[dffull['ValueType'] == 'BPBASE'][['Cover','ObjectType','Value']].fillna('NULL')6,246 words
ABACUS Rating Engine — Reference
Scope: src/Analytics/riskmodel on branch feat/prem-payment-procedures (master lineage), plus the IPAL-side caller in PasPremium. Corroborated against live SRD_ANLT data.
Path convention below: rating/…, api/… = src/Analytics/riskmodel/App/…. C# paths are given in full.
Verification method: code read end-to-end for the rating path; every structural claim cross-checked against live rows (PPA_PRODUCT_VERSIONS, PPA_PRODUCT_COVERS, PPA_PRODUCT_FILES, PPA_FACTORS, CUSTOM_FILTERS); absence claims established by repo-wide grep for readers, not just writers. I could not execute the engine or sympy in this sandbox (pip blocked) — the two places where that matters are flagged inline.
0. Corrections to your stated premises
| | Your premise | Verdict
| Rates in PPA_PRODUCTS → PPA_PRODUCT_VERSIONS → PPA_PRODUCT_COVERS / PPA_PRODUCT_LD, grids as JSON in PPA_PRODUCT_FILES keyed by FILE_NAME; COVER_BP_RATING/LD_RATING hold the file name | Correct. rating/deployment/gen_rule_table.py:273,335 → load_rule_db → PpaProductFiles.get_file selects content by file_name (rating/ppa/ppa_product_files.py:25-42).
| genRuleTable compiles grids into RATING_TABLE_FILES and resets the LRU cache | Correct but incomplete. It writes two columns in two passes: CSV_TABLE (gen_rule_table.py:142-168) and then, via gen_interpreter() → gen_dict_table() → save(), the pre-compiled JSON (rating/table_interpreter.py:770-796). The cache reset (api/common_api.py:269-270) rebinds self.table_interpeter_cache on the one process that handled that message only — other workers/replicas keep their stale RATING_TABLE_FILES JSON in their own LRU until they get an invalidateCache (common_api.py:117-121).
| BP_RATE_DIM constrained to P/V/M | Correct, but it is an application assert in the deploy path (gen_rule_table.py:35), not enforced at read time. LD_RATE_DIM additionally allows R (gen_rule_table.py:42).
| A version carries TEMPLATE_CODE, LD_RULE, TAX_RULE, EFFECTIVE_DATE, VERSION_STAGE | Correct, plus CUSTOM_FILTER_ID, P_WP_FORMULA, COMPLETE_RULESET_FILE, DESCR (rating/ppa/ppa_product_versions.py:24-45). CUSTOM_FILTER_ID is the one that changes pricing behaviour.
1. BP_BASE — grammar, resolution, ordering
1.1 How it gets into the compiled table
BP_BASE and BP_SHIFT are emitted as pseudo-rows by add_bp_table (gen_rule_table.py:46-51, called at :120-121): one row per cover with ValueType='BP_BASE' (resp. 'BP_SHIFT'), Value=<the column text>, Cover, ObjectType. They ride in the same CSV as the rate rows.
1.2 The parser — exactly two branches
rating/table_utils.py:190-204:
df = df_full[df_full['ValueType'] == 'BP_BASE'][['Cover','ObjectType','Value']].fillna('NULL')
bp_base_map = df['Value'].to_dict()
for cov, f_str in bp_base_map.items():
if f_str[:3].upper() == 'BP(' and f_str[-1] == ')': # branch 1
dest_cover = f_str[3:-1].strip().upper()
assert dest_cover in {k[0] for k in all_values_map.keys()}, f'Unrecognized cover {dest_cover}'
bp_substition[cov] = dest_cover
continue
bp_base_formulas[cov] = sympy.parsing.sympy_parser.parse_expr(f_str) # branch 2
bp_base_factors[cov] = get_symbols(f_str)
Branch 0 — the literal 'NULL'. .fillna('NULL') at :191 means a real SQL NULL becomes the 4-character string NULL, and get_values branches on dict_table['bp_base'][cov] != 'NULL' (table_utils.py:401). A cover with no base must carry the string 'NULL' or a real NULL — both land in the same place. But note validate_covers (gen_rule_table.py:34) asserts BP_BASE is not None before any fillna runs, so a real NULL fails deployment. The literal string is the only workable value.
Branch 1 — substitution. The test is f_str[:3].upper()=='BP(' and f_str[-1]==')'. That means BP(<COVER>) must be the entire expression. IV*BP(CA_FULL) does not match and falls through to branch 2. In branch 2 sympy's auto_symbol transformation turns an undefined NAME( into an applied undefined function, so BP becomes a function and CA_FULL becomes a free symbol — which then gets demanded as a request factor by validate_features (table_interpreter.py:205-211) and by the subs comprehension at table_utils.py:403-404. Net effect: a mixed expression fails as Missing features {'CA_FULL'} or a KeyError, not as a parse error. (This one step — sympy's exact handling of BP(X) under standard_transformations — is the one claim I inferred from sympy's documented auto_symbol behaviour rather than executed; the surrounding code path is read directly.)
Only one BP() term is expressible, because the whole string is consumed.
Branch 2 — sympy expression. parse_expr with sympy defaults: full arithmetic (+ - * / **), parentheses, numeric literals, and the entire sympy global namespace as callables. That is where Max(SEATS-1,1) works — Max is sympy.Max; Min, Abs, sqrt, exp, log, floor, ceiling, Piecewise are all reachable by the same mechanism. Any bare name that is not a sympy symbol becomes a free Symbol.
Identifiers resolve to request factors — nothing else. get_symbols (table_utils.py:54-55) returns the free symbols; those become bp_base_factors[cov], and evaluation is evalf(subs={k: factors[k] for k in bp_base_factors[cov]}) (table_utils.py:403-404). They are not object attributes and not other covers. IV, NUM_INS_OBJ, SEATS, PASSENGER_NUM, CREW_NUM, IV_GRPPA are all just PPA_FACTORS.PRICING_FACTOR_CODEs that must arrive in request['factors'].
1.3 Evaluation
table_utils.py:392-410, in get_values, for the group BP:
if bp_substition: substitute_baseprem(...) # branch 1
elif bp_base != 'NULL':
PureRate = <grid value>
Value = <grid value> * bp_base_formula.evalf(subs=factors)
BpBase = <the BP_BASE text>
Value += bp_shift
ShortValue= get_short_rate(Value, ...)
else: Value += bp_shift ; ShortValue = get_short_rate(...)
So: BP = grid_rate × eval(BP_BASE) + BP_SHIFT, then short-rated.
substitute_baseprem (table_utils.py:337-353) has a dimension-dependent asymmetry that is easy to get wrong:
if bp_dct['ValueType'] == 'P':
bp_dct['PureRate'] = bp_dct['Value']
bp_dct['Value'] = values[sub_key]['BP']['Value'] * bp_dct['PureRate']
else:
bp_dct['Value'] = values[sub_key]['BP']['Value'] # grid rate DISCARDED
With BP_RATE_DIM='P', BP(X) means this cover's grid rate applied to X's base premium. With any other dim it means a verbatim copy of X's base premium and the cover's own grid is silently ignored. BpBase is relabelled "BP(<X>)" for reporting.
1.4 Ordering and cycles
all_values_map is an OrderedDict built from covers sorted by the Order column — which get_bp_table populates from PPA_PRODUCT_COVERS.CALC_ORDER (gen_rule_table.py:315):
df_red = sorted(..., key=lambda x: np.inf if np.isnan(x[1].iloc[0]['Order']) else x[1].iloc[0]['Order'])
table_utils.py:133-134. Null CALC_ORDER sorts last. ('NAN','NAN') is then force-moved to the very end (:156-157).
get_values iterates that order (:363) and substitute_baseprem reads values[sub_key]['BP'] (:341/345). Therefore the referenced cover must have a strictly lower CALC_ORDER. Confirmed live on 4704 version 5342: CA_FULL is CALC_ORDER=20; CA_RACE/CA_STRIKE/CA_CLMNODOC/CA_WAVE, all BP_BASE='BP(CA_FULL)', are 100–102.
There is no cycle detection. The only guard is the existence assert at table_utils.py:197, which a self-reference also passes. A cycle, a self-reference, or a forward reference all produce the same thing: an unwrapped KeyError out of values[sub_key] at table_utils.py:341 or :345, surfacing as a generic failure with no mention of covers or ordering.
Two later passes exist to repair overrides: override_rates sorts overrides so substituted covers go last (table_interpreter.py:248-249), and recalculate_rates (:234-241) re-runs substitution for covers whose base changed.
1.5 LD_RATE_BASE
gen_rule_table.py:359 passes prem_base=ld['LD_RATE_BASE'] into convert_table, landing as the PremBase column on every LD row. parse_base_prem (table_utils.py:58-70) then always lambdifies it — same sympy grammar as BP_BASE, plus BP itself is in scope:
if prem_base_str != 'BP' and prem_base_str in factors and dct['PremType'] == 'LD':
dct['BasePremFactor'] = prem_base_str
dct['PremBase'] = {'func': lambdify(...), 'symbols': {...}, 'expr_str': prem_base_str}
Three distinct consumers:
- scale_ld_base (:248-256) — only when ValueType == 'M' and PremType in ('LD','TAX'): Value *= PremBase.func(**factors). This is what makes M mean "per unit of LD_RATE_BASE".
- BasePremFactor (table_interpreter.py:567-572) — if LD_RATE_BASE is a bare factor name (not BP) on an LD, the symbol fed to the WP formula becomes Value × factors[LD_RATE_BASE], for any rate dim.
- Short rate (get_short_rate:310-323) — Value * PremBase.func(...) with BP bound, else Value * factors[PremBase], else asserts PremBase == 'BP'.
validate_lds (gen_rule_table.py:40-43) only requires LD_RATE_BASE non-null; the value whitelist is commented out, so any parseable expression is accepted.
Hardcoded trap: exclude_keys_from_dict (table_utils.py:331-335) strips CAR_MODEL, CAR_MAKE, Q_DAMAGES from the substitution dict before evaluating PremBase. If an LD_RATE_BASE references one of those three, it fails with a KeyError. The comment at :251-253 explains why (values like S-MAX parse as subtraction), but the fix is a hardcoded blacklist, not an escaping rule.
1.6 LD_FORMULA and C_WP_FORMULA — machine-generated, and one is dead
GenFormula builds the per-cover WP expression, writes it to PPA_PRODUCT_COVERS.C_WP_FORMULA, then differentiates it into one LD_FORMULA per LD:
acc = [WP.subs({modifiers[i]: 0 if dims[i] != 'R' else 1 for i in range(k, n)}) for k in range(0, n+1)]
return [sympy.simplify(s2 - s1) for s1, s2 in zip(acc[:-1], acc[1:])]
gen_formula.py:136-139. Each LD_FORMULA is the marginal contribution of that LD given all preceding ones — which is why the per-LD amounts sum exactly to WP−BP. str(del_WP) == '0' rows are skipped (:132, :278), leaving the previous LD_FORMULA in place.
C_WP_FORMULA is never evaluated at runtime. get_formula_table writes the cover WP formula with Group='' (gen_rule_table.py:179), which round-trips through CSV as NaN → 'NAN'. Both gen_lambdified_dictionary (table_utils.py:459-462) and get_rates (table_interpreter.py:621-623) then do:
if gr == 'NAN' and cov[0] != 'NAN':
gr = 'BP'; form_dct = {'Value': 'BP'}
The cover-level formula entry is replaced by the identity BP. C_WP_FORMULA exists solely as the source that generate_breakdown differentiates. Runtime WP is assembled arithmetically instead: wps[cover] += every rate row where premType != 'TAX' (table_interpreter.py:693-700), then append_wps (table_utils.py:430-440).
This is the single most important thing to tell your agent: editing C_WP_FORMULA by hand changes nothing at quote time. It is regenerated on every genRuleTable (gen_formula.py:272-273, gated by populateFormulas) and is not the thing that runs.
2. What P / V / M / R actually do
Two separate mechanisms, and they compose.
2.1 Normalisation (all dims, at compile → dict-table time)
normalize_value (table_utils.py:46-51): Value = float(Value), and if ValueType == 'P', Value /= 100. Nothing else is scaled. On the way out, rate is re-multiplied by 100 for P (table_interpreter.py:677).
2.2 For BP_RATE_DIM
The base-premium branch in get_values (table_utils.py:401-410) keys off BP_BASE != 'NULL', not off the rate dim. So:
| | BP_RATE_DIM | BP_BASE | Arithmetic
| P | expression | BP = (grid/100) × eval(BP_BASE) + BP_SHIFT — a percentage of the base
| P | BP(X) | BP = BP(X) × (grid/100)
| V | 'NULL' | BP = grid + BP_SHIFT — a flat amount
| V | expression | BP = grid × eval(BP_BASE) + BP_SHIFT — still multiplies; V does not suppress the base
| M | either | Arithmetically identical to V. scale_ld_base is gated on PremType in ('LD','TAX'), so a BASEPREM row never gets the PremBase multiplication.
| R | — | Not legal for BP_RATE_DIM (gen_rule_table.py:35)
M vs V on a cover therefore differs only in (a) the rateDim string echoed back in the response, and (b) which GenFormula branch the cover's LDs take. Treat M on BP_RATE_DIM as cosmetic.
2.3 For LD_RATE_DIM — this is where the dims are real
GenFormula.generate_formula / _compute_formula (gen_formula.py:77-98, :185-207), folding LDs in LD_ORDER sequence, with m = the LD symbol and s = LD_SIGN:
| | LD_RULE (convention) | dim | Effect on WP
| SUM | P | WP += BP * m * s — percentage of BP only, never of the running total
| SUM | V, M, R | WP += m * s — flat addition
| MULT | P | WP *= (1 + m * s) — multiplicative on the running total
| MULT | R | WP *= m — raw multiplier, no 1 +
| MULT | V, M | WP += m * s — falls back to additive
| BPSHIFT | V, M only (asserted :89) | accumulates into BP_SHIFT; afterwards WP = WP.replace(BP, BP + BP_SHIFT) (:98) — shifts the base before every other modifier
| anything else | — | BadRequest(f'Unknown convention {c}') (:95)
Then, orthogonally, at runtime: M multiplies the looked-up grid value by eval(LD_RATE_BASE) (scale_ld_base). That is the "per seat / per day / per unit" dimension. R is a pure ratio and is the only dim excluded from the subs → 0 step in generate_breakdown (it substitutes 1 instead, gen_formula.py:138).
3. The getRates pipeline, in order
Entry: api/common_api.py:278-297. A fresh CustomInterpreter is constructed per request (:287) sharing only the LRU cache; the connection is released in a finally (:291).
CustomInterpreter.get_rates (rating/custom_interpreter.py:52-64) is the real orchestrator:
1. set_product_code_and_date → product_id, country_id, apply_max_df (custom_interpreter.py:66-74)
2. set_template_code → resolve/validate templateCode (table_interpreter.py:482-487)
3. set_version_id → the DEPLOYED version (table_interpreter.py:131-150)
4. set_filters(filter_id, version) → pre/post filter instances (custom_interpreter.py:102-146)
5. do_prefilter(request) → PRE-FILTER mutates the request
6. TableInterpreter.get_rates(...) → the core computation (below)
7. do_postfilter(response, request) → POST-FILTER rewrites rate rows
8. restore_dict / reset_fields
9. rates_overwriter.do_overwrite → manual-premium + duration fixups (rating/ppa/rates_overwriter.py)
Inside the core, TableInterpreter.get_rates (table_interpreter.py:492-706):
| | # | Step | Lines
| 1 | asserts productCode, date, objectType; sets isQuote | 494-501
| 2 | bp_threshold = post_filter.get_st(factors) — the post filter is queried before pricing | 502, 878-885
| 3 | campaign covers resolved from custom_modifiers.campaign_map | 504-507
| 4 | load() — dict table from RATING_TABLE_FILES.JSON via LRU; on any exception, silently regenerates from CSV_TABLE | 512, 747-759
| 5 | dict_overwrite — DictTableOverwriter rewrites bp_base/bp_shift for products 2204/2214/2215 only | 513, rating/ppa/dict_table_overwriter.py:5
| 6 | validate_features — every grid feature plus every BP_BASE symbol must be in factors | 515, 202-211
| 7 | currency map + local_ccy; BG is hardcoded to EUR (:173-175), everything else via ccy.countryccy | 517-522
| 8 | gen_currency_fx_map — per-cover fx, no cross-currency (assert at 421) | 522, 398-430
| 9 | enrich_overrides — fans a policy-level override out across selectedCovers | 524, table_utils.py:443-452
| 10 | get_values — grid lookups → BP (incl. BP_BASE, BP_SHIFT, short rate) and every LD value | 528-531
| 11 | override_rates — caller-supplied overrides replace grid values; rateDim='V' ⇒ absolute amount | 537, 243-317
| 12 | recalculate_rates — re-substitute dependent covers whose base moved | 539
| 13 | per cover, per group: evaluate the lambdified LD_FORMULA → amnt / shortAmnt / amntLocal | 612-648
| 14 | max-discount cap applied here, mid-loop | 598-605, 654-662
| 15 | build the rate row; rate field = Value × Sign × (100 if P else 1) | 664-685
| 16 | accumulate WP = Σ non-TAX rows per cover | 693-700
| 17 | append_wps, aggregate_rates (policy-level LDs → one row, coverCode=None), apply_cover_groups | 702-705
Where each control actually lives
Short rate — computed at step 10, inside get_values, at the moment BP is set (table_utils.py:407/410). It is not a later adjustment: every rate row carries shortAmnt/shortAmntLocal computed from ShortValue through the same formula (table_interpreter.py:628-632). get_short_rate (table_utils.py:303-329): an applyAlways P short rate with value 12 means DURATION_MONTHS/12 × BP, value 365 means year_fraction(start, DURATION_DAYS) × BP (process_proportional_duration:287-300); anything else raises. Non-applyAlways uses the grid: P/M/R scale, V is absolute.
Base premium — step 10. LDs — steps 10 (values) and 13 (formula evaluation). Taxes — LDs with LD_TYPE='TAX'; identical machinery, excluded from the WP sum at :693.
PREMIUM_MIN / PREMIUM_CAP — ignored by the engine. Repo-wide, the only reader is PostFilterCapFloor.set_trimmed_covers (rating/filters/post/post_filter_cap_floor.py:92-100), which must be wired in as a CUSTOM_FILTERS.POSTFILTER_CLASS. When it is wired, it compares WP.amnt against the bounds, and if breached it discards the whole rate set for that cover and rebuilds it as a single flat BP/WP pair at the bound plus recomputed taxes (:50-88) — the discount and loading rows are thrown away, not scaled.
Verified live: every 4704 CASCO cover on version 5342 carries PREMIUM_MIN = 240, but every 4704 version points at CUSTOM_FILTER_ID 1000084 or 1000120, i.e. post_filter_4704_v2 / _v2_eur — not post_filter_cap_floor. So on 4704 that column is dead data, and the real 240 is a Python literal (post_filter_4704_v2.py:79). This is exactly the kind of thing your agent must not infer from the column existing.
DISCOUNT_PRC_MAX / RENEWAL_PRC_CAP — engine-applied, triple-gated.
- Gate 1: PPA_PRODUCTS.APPLY_MAX_DF = 'Y' (table_interpreter.py:447-450; the loaded max_dfs are only restored from cache when this is true, custom_interpreter.py:84-86).
- Gate 2: set_max_dfs selects only rows where both columns are non-null (:825-826). One of the two null ⇒ no cap at all.
- Gate 3: NEW_CLIENT factor (default 'Y') picks which of the two applies (:600-605).
Mechanics (:598-605, :654-662): max_df_amnt = pct × BP / 100 + 1e-10. Discount rows are processed largest-value-first (ds_codes sorted by -Value, :610); each is accumulated until the budget would be exceeded, at which point that LD is zeroed outright (mult_scale = 0.0 and its symbol is set to 0 for subsequent formulas). It is not pro-rated. MAXDF_EXEMPT='Y' LDs bypass it entirely (exempt_lds, :830-845), and the post filter can veto specific LDs via get_excluded_dfs (:607, :869-876).
DISCOUNT_AMNT_MAX / RENEWAL_AMNT_CAP — dead. Written by ppa_product_covers.create_row (:40,42) and carried through open_for_change.py; no reader anywhere in the engine.
4. LD_RULE and TAX_RULE
Legal values. Version level: assert row['LD_RULE'] in ['SUM','MULT'] and the same for TAX_RULE (gen_rule_table.py:27-28). Per-LD override PPA_PRODUCT_LD.LD_RULE additionally accepts BPSHIFT (gen_formula.py:88); anything else is BadRequest at deploy.
How they're combined. gen_formula.py:64-67:
self.conventions = df['LD_TYPE'].map({'LD': ld_rule, 'TAX': tax_rule}).values
if np.any(df['LD_RULE'].notna()):
self.conventions[df['LD_RULE'].notna()] = df['LD_RULE'][df['LD_RULE'].notna()].values
LD_TYPE selects which version-level rule applies; a non-null per-row LD_RULE overrides it. So LD_RULE/TAX_RULE are defaults, and per-LD LD_RULE is the escape hatch.
Gotcha: validate_version validates the DB row, but generate_formulas uses self.ld_rule/self.tax_rule taken from the genRuleTable request payload (gen_rule_table.py:89, 95-96, 370-378). If the request sends different values than the row holds, the row passes validation and the request values are what get compiled.
What they're applied to. Because the result is a single algebraic expression per cover, "applied to" is set by the fold order, not by an imperative pipeline. LDs are ordered by LD_ORDER (:57, :161, :267) and folded left-to-right into WP:
- Under SUM, every proportional LD is a percentage of BP alone — order is irrelevant to the result.
- Under MULT, each proportional LD multiplies the running total, so it compounds with everything before it — order changes the answer.
- TAX rows are folded into the same expression, so LD_RULE='SUM' + TAX_RULE='MULT' yields WP = (BP + Σ discounts) × (1 + tax) — tax on the net. That is exactly the 4704 configuration (all versions: LD_RULE=SUM, TAX_RULE=MULT).
Scope — which covers get which LD (gen_formula.py:245-296):
| | PPA_PRODUCT_LD row shape | Included in
| COVER_ID = a real cover | that cover's WP formula
| COVER_ID = the COVER_CODE='NAN' cover of the object type | every cover of that object type (:255-261)
| COVER_ID NULL, LD_TYPE='LD', LD_RATE_DIM='P' | every cover's formula (pol_prop_lds, :234-238, :264)
| COVER_ID NULL, (LD_TYPE='LD' and dim V) or LD_TYPE='TAX' | a separate policy-level pass that emits LD_FORMULAs only, no cover WP (:282-296)
| LD_TYPE='SR' | excluded from formulas entirely; becomes the cover's short-rate rule
At runtime, policy_level_lds (table_utils.py:118-123) = the groups on ('NAN', <object>) rows that are ValueType='P' and PremType='LD'. Those are computed once per selected cover, zeroed for unselected ones (table_interpreter.py:563-564, 616-620), then summed into a single row with coverCode=None (aggregate_rates, table_utils.py:417-427). They contribute to each selected cover's WP but are reported once.
This contradicts a prior note in memory that a COVER_ID IS NULL LD applies "to the LEAD cover only". On this branch it applies to every selected cover and is reported once. If that note came from a different branch, the two have diverged — verify against the code before acting on it.
5. TEMPLATE_CODE
5.1 What it scopes — engine side
TEMPLATE_CODE is part of the version-resolution key:
SELECT version_id, effective_date, custom_filter_id FROM ppa_product_versions
WHERE product_id=:p AND template_code=:t
AND effective_date <= :date AND version_stage='DEPLOYED'
ORDER BY version_id DESC -- rownum <= 1
table_interpreter.py:131-145.
Because a version owns its covers, LDs, grid file references, LD_RULE/TAX_RULE, CUSTOM_FILTER_ID and effective date, a template scopes all of it. Two templates for the same product are two entirely independent tariffs that happen to share a product code and a factor dictionary (PPA_FACTORS is keyed on PRODUCT_CODE, not on version — gen_rule_table.py:63).
So, answering directly: a template is a namespace for a complete parallel rating configuration. It is not intrinsically a channel, a payment term, or a multi-year term. Any such meaning is imposed entirely by whoever chooses the code at quote time.
Selection gotcha: the ORDER BY is version_id DESC, not effective_date DESC. Among all DEPLOYED versions with effective_date <= quote_date, the one with the highest VERSION_ID wins. This matters in practice: a template can carry several DEPLOYED rows sharing one effective date. Inserting a new version with an earlier effective date still overrides the older one, as long as it is not in the future.
5.2 Fallback chain
set_template_code → get_template_code (:458-479): if the requested code is among the product's DEPLOYED codes at that date, use it; else fall back to the default and log a warning; else fall back to df['TEMPLATE_CODE'].iloc[0] — an arbitrary row — and log an error. It never raises. A typo in the template code therefore prices silently against some other tariff. The default comes from SRD_SYS.ENV_SETTINGS key ABACUS_DEFAULT_TEMPLATE_NAME, falling back to the literal 'DEFAULT_TEMPLATE' (rating/ppa/template_service.py:11-12, 26-36).
5.3 What picks it at quote time — IPAL side
PasPremium/PasPremium.Service/Services/PremiumEngine/RatingsEngineCaller.cs:46:
TemplateCode = context.TemplateOfferCode ?? polObject.OfferCode,
Default: the template code is the offer code on POL_OBJECTS. TemplateOfferCode is an override written by BeforePremiumCalculation operations (Ablera.Serdica.Operations.PremiumCalculation/BeforePremiumCalculation/), all configuration-driven:
| | Operation | Rule
| TemplateSelector.cs:34-44 | TemplateOfferCode → PolObject.OfferCode → LB_OFFER_PRICING_RULE.TEMPLATE_CODE for that offer+product → configured DefaultTemplate → DefaultTemplate constant. Then optionally rewrites it with a configured s/pattern/replacement/ regex (:47-61).
| CoverBasedTemplateSelector.cs:32-49 | first selected cover found in a configured CoverTemplateMapping wins
| PlSqlTemplateCodeSelector.cs:38-96 | calls a configured PL/SQL proc with pio_template_code in/out; non-empty return overrides
| AgentClientOfferCustomizer.cs:34-38 | on agent-code/branch or client-ID match, sets it to the configured ObjectOfferCode
| …Bulstrad.LobMotor/Product4704/…/GapOfferCodeCustomization.cs:64 | product-specific override
The chain is ordered by the OperationRunner, and each later operation sees the previous one's value.
5.4 The 4704 _EUR twins — what they actually are
Live PPA_PRODUCT_VERSIONS for 4704:
| | Template family | CUSTOM_FILTER_ID | Post filter
| DEFAULT_TEMPLATE, 2MOL, 3MRC, 4MFL, 5MDS, AVVI, BROKERINS2 | 1000084 | post_filter_4704_v2
| every *_EUR twin, plus BL_EUR, 4704_ONLINE_* | 1000120 | post_filter_4704_v2_eur
And diff post_filter_4704_v2.py post_filter_4704_v2_eur.py is two lines:
< self.abs_min_premium = 240.0 > 240.0/1.95583
< self.cond_min_premiums= [500.0, 600.0] > [500.0/1.95583, 600.0/1.95583]
1.95583 is the fixed BGN/EUR rate. So the _EUR twin is a currency redenomination of the same tariff — same rules, grids restated in EUR, minimum-premium literals divided by the peg. The s/…/…/ regex hook in TemplateSelector is the mechanism that appends the suffix; git history confirms this pattern existed (c316527a4c "Drop _EUR suffix mutation from corrections_overrider default template"). I could not establish the exact configured regex value — it lives in operation configuration data, not in the repo.
The other families (2MOL, 3MRC, 4MFL, 5MDS) share one filter, one LD_RULE/TAX_RULE and one effective date, differing only in their grid files — consistent with instalment/term variants, but the code does not say so; nothing in the engine attaches meaning to those names.
6. Filters
6.1 Model
BaseFilter rating/filters/base_filter.py
├─ BasePreFilter do_filter(request) → request (no-op)
└─ BasePostFilter do_filter(response, request) → response (no-op)
A filter is arbitrary Python that sits on either side of the core computation. Pre may rewrite anything in the request (factors, overrides). Post receives the finished {'rates':[…]} plus the request and may rewrite, add, or delete any rate row.
6.2 Registration and selection
PPA_PRODUCT_VERSIONS.CUSTOM_FILTER_ID → CUSTOM_FILTERS. Two authoring modes (custom_interpreter.py:106-146):
- FILTER_TYPE='CLASS' — PREFILTER_CLASS/POSTFILTER_CLASS hold a module file name. load_filter (:149-169) resolves rating/filters/{pre|post}/{class_name}.py, exec_modules it, and instantiates the first class in the module that subclasses the base. So the class name inside the file is irrelevant; the file name is the contract, and the file must be in the deployed image. Missing file ⇒ ResourceNotFound, i.e. the request fails loudly. If instance has a version_id attribute it is injected (:165-166), then gen_filter(effective_date) runs.
- FILTER_TYPE='BLOB' — dill-pickled objects in PREFILTER_BLOB/POSTFILTER_BLOB, uploaded via the uploadCustomFilters command (rating/deployment/upload_custom_filters.py:11-48; both pre and post are mandatory, :15-16). Load failures here are caught and logged (:132-133, :141-142), leaving the no-op base filter and producing an unfiltered premium instead of an error. That silent-degradation risk applies to BLOB only, not CLASS.
Live CUSTOM_FILTERS — all 8 rows are CLASS:
| | ID | pre | post
| 1000080 | pre_filter_4710_v1 | post_filter_4710_4711_v1
| 1000081 | pre_filter_4704_v1 | post_filter_4704_v1
| 1000082 | pre_filter_cap_overrides_v1 | post_filter_3602_v1
| 1000083 | pre_filter_cap_overrides_v1 | —
| 1000084 | pre_filter_4704_v1 | post_filter_4704_v2
| 1000100 | corrections_overrider_v1 | —
| 1000120 | pre_filter_4704_v1 | post_filter_4704_v2_eur
| 1000121 | pre_filter_4710_v1 | post_filter_4710_v2
Performance note: set_filters is called on every request (custom_interpreter.py:56) and the @cachedmethod on get_filters is commented out (:105). Filters are re-read from Oracle, re-exec_moduled and re-instantiated per quote — and PostFilterCapFloor.gen_filter opens its own Oracle connection and runs two more queries each time (post_filter_cap_floor.py:129-137).
6.3 The post filter runs during the core computation too
Not only afterwards. TableInterpreter reaches back into self.post_filter twice:
- get_bp_threshold → post_filter.get_st(factors) at the very top of get_rates (:502, :878-885)
- get_excluded_dfs → post_filter.get_excluded_dfs(threshold, bp, factors) inside the per-cover loop (:607, :869-876)
Both are hasattr-guarded and both swallow exceptions into a 0.0 / set() default. So a post filter can influence discount capping mid-computation, and a bug in it degrades to "no threshold" silently.
6.4 What a filter expresses that a grid cannot
A grid is a pure function (factors) → one number, per cover, per LD. A filter can do everything a grid cannot: read the assembled result across covers, compare aggregates, mutate the request, and rewrite rows. Concretely, from the shipped examples:
- Derive a factor — pre_filter_4704_v1.py:7-10: REGION_REGNO = re.search(r"[A-Z]*", CAR_REGNO), i.e. region from the licence-plate prefix. No grid can compute an input.
- Clamp an override — pre_filter_cap_overrides_v1.py:11-18: any COMMERCIAL override is clamped to ±40 regardless of what the user asked for.
- Cross-cover minimum premium with redistribution — the 4704 filter (below).
- Rebase a cover on another cover's written premium — grids can only reference another cover's base premium via BP(X).
6.5 post_filter_4704_v2 in business terms
(rating/filters/post/post_filter_4704_v2.py; _eur is the same logic with EUR literals.)
- Compute the applicable minimum premium for this risk from CAR_SUBTYPE, SEATS_NUM, TONAGE, SERVICE_USAGE, CAR_AGE (get_min_premium:86-100). Three tiers: an absolute floor and two conditional floors.
- Top up under-priced CASCO covers. For CA_FULL, CA_FULL_B, CA_FR_ND, CA_FR_ND_COLL, CA_FR_ND_CL_VND (:12), if the accumulated written premium is below the floor, the shortfall is written into the CORRECT_PREM_SERV LD row (filter_rate:177-183), the "sufficiency-exempt" loadings and the taxes are scaled proportionally (scale_lex, scale_tax; add_lump_sum:27-37), and the LD rows that already bit are frozen so the top-up does not double-count them (frozen_rates:32). Which running total the floor is compared against depends on SERVICE_USAGE: EXPERT_EVALUATION uses WP1 (excluding conditional and exempt LDs), everything else uses WP7 (do_filter:248-264).
- Honour a manual escape. Factor OVERRIDE_MINPREM='Y' skips the whole top-up (should_apply_minprem_override:220-234). A manual CORRECT_PREM_SERV override also removes that cover from the filtered set (remove_corrected_covers:206-214).
- Rebase dependent covers. CA_WAVE, CA_STRIKE, CA_RACE, CA_CLMNODOC are configured with BP_BASE='BP(CA_FULL)'; the filter rescales them to CA_FULL's written premium instead, relabelling bpBase as WP(CA_FULL) (enrich_dependent:18-24, filter_rate:155-165). Annexes rebase all four; new business rebases only CA_CLMNODOC (:265-272).
- Recompute a compounding loading. N1_CASCO_LOADING is recomputed on BP + all other LDs rather than on BP alone (recalc_rates_from_group_ld_9:280-326) — a MULT-like behaviour inside an otherwise SUM product. Then PFEE is recomputed on the new WP (recalc_taxes:328-353).
- Feed the core engine. get_st and get_excluded_dfs (:102-120) are the callbacks described in §6.3: under a specific SERVICE_USAGE/threshold combination, three named discounts are excluded from the discount budget.
v1 → v2 is a pure parameter/scope change: max discount 40%→45%, secondary 30%→35%, floors [440,500]→[500,600], two more LDs added to the exempt and conditional sets, and the date-conditional branch removed.
7. The grid format
7.1 Envelope
{ "ldFileName": "<FILE_NAME>",
"ldRuleType": "multipleRow" | "singleRow",
"ldFileRules": {
"ruleItems": [], "applyAlways": null, "modifierAmount": null, "defaultValue": null,
"rows": ["<factor name>", …], "columns": ["<factor name>", …],
"ruleTable": "<CSV>" } }
Stored in PPA_PRODUCT_FILES(FILE_NAME, CONTENT CLOB, DEPLOY_ID).
ldRuleType and ruleItems are never read by any code. Grep across .py/.cs: ldRuleType appears only at four write sites (convert_table.py:191,311, upload_model.py:53, deploy_model.py:52, life/model_builder.py:324) and ruleItems only at three, always as []. The engine dispatches on applyAlways / model_info / defaultValue instead (gen_rule_table.py:274-324, 336-367). Treat both as UI metadata; do not let the agent make them load-bearing.
Validation at load (load_rule_db:226-238): ldFileRules and ruleTable are always required; rows and columns are required unless model_info is present.
7.2 The four modes
| | Mode | Trigger | Result
| Flat | applyAlways: true (requires modifierAmount non-null) | one row, {'Always': True} — no factor axes, get_value returns it directly (table_utils.py:76-86, 272-273)
| ML model | ldFileRules.model_info.model present (BP only) | value becomes "model_id:<id>" → ModelInference(id), inferred per request (gen_rule_table.py:274-290; table_utils.py:80-83, 274-277)
| Complete grid | rows and/or columns, no defaultValue | convert_table (convert_table.py:116-134)
| Incomplete grid | defaultValue non-null | convert_defaultrule (:137-154) — flat CSV, last column is the value, and a DefaultValue column is stamped on every row
enrich_args (gen_rule_table.py:248-254) makes these mutually exclusive: if defaultValue is set, rows/columns are not passed through.
7.3 CSV indexing
cvs_to_df(str_table, n_rows, n_cols) = pd.read_csv(io.StringIO(unescaped), header=list(range(n_cols)), index_col=list(range(n_rows))) (gen_utils.py:71-76).
So len(columns) = the number of header lines and len(rows) = the number of leading index columns. Then df.stack(...) flattens to one value per full label tuple (convert_table.py:117-124). Special case: rows == [] requires exactly one column factor (:119-122).
Live example, 4704_CA_DAMAGE_921_10Sept — rows: ["Car type","Engine Capacity"], columns: ["Car Usage","Car Age"], so 2 header lines and 2 index columns:
,,Car Usage in (PERS),Car Usage in (PERS),Car Usage (Other),Car Usage (Other)
,,Car Age(<7],Car Age(>7),Car Age(<7],Car Age(>7)
Car type in (CAR;TRUCKN1),Engine Capacity(<2000],100,100,100,100
Car type in (CAR;TRUCKN1),Engine Capacity(>2000),150,150,150,150
Car type (Other),Engine Capacity(<2000],100,100,100,100
Car type (Other),Engine Capacity(>2000),150,150,150,150
And the flat form, 4704_CA_RACE_406: "applyAlways": true, "modifierAmount": 10, "rows": [], "columns": [], "ruleTable": "" — with ldRuleType: "singleRow", which nothing reads.
7.4 Label grammar
Every cell label must be <Factor Name><suffix>, where the factor name is a literal prefix (get_limits:76-92, get_in_levels:58-68):
| | Form | Meaning
| F (<= N] | (-inf, N) — get_limits:82
| F (> N) | (N, +inf) — :83-84
| F (A - B] | (A, B] — :85-86
| F in (A;B;C) | discrete level set, semicolon-separated, upper-cased — get_in_levels:62-64
| F (Other) | the catch-all bucket
get_number takes the first \d+\.\d+ if present, else the first \d+ (:70-74). Anything else raises BadRequest('Cannot parse numerical/categorical label!').
Ranges vs discrete values are decided by PPA_FACTORS.DATATYPE, not by the label: type_map maps NUMBER/IV → NUMBER, everything else → STRING (gen_rule_table.py:21, 66). A numeric factor typed LIST will be parsed with the categorical branch.
7.5 Bin construction and lookup
get_nodes (table_utils.py:27-43):
- NUMBER — collect all (lower, upper) pairs, sort the union of endpoints into nodes, and assert the pairs exactly tile the nodes: assert full_tuples == tuples, 'Nodes not consistent with bins!' — waived only when incomplete (i.e. a defaultValue exists). A gap or an overlap in a complete grid is a deploy-time failure with that exact message.
- STRING — level list with OTHER forced to the last position (:37-38).
get_bin_index (:230-237):
- NUMBER → np.digitize(v, nodes, right=True) - 1. Intervals are left-open, right-closed (lower, upper]. A value at or below the first node returns -1 unless a -inf node exists — so always anchor the grid with a (<= N] row.
- STRING → index of the first level list containing the upper-cased value, else len(nodes) = the OTHER slot.
get_value (:271-284) — on a miss:
assert 'DefaultValue' in cov_gr_dct, 'Default value is required when rule is incomplete!'
An AssertionError, not a zero and not a skip. A grid must either be complete (asserted at compile) or carry defaultValue.
8. Covers ↔ factors: what must agree
Three registries have to line up.
PPA_FACTORS (keyed on PRODUCT_CODE, not on version — gen_rule_table.py:63):
| | Column | Role
| PRICING_FACTOR_NAME | the grid axis name and the label prefix inside ruleTable
| PRICING_FACTOR_CODE | the key in request['factors'] and the compiled CSV column name
| DATATYPE | NUMBER/IV → numeric bins; anything else → categorical levels
| QT_REQUIRED | available at quote stage
| HAS_CURRENCY | this factor is an insured value
set_maps (:62-66) sets the DataFrame index to PRICING_FACTOR_NAME and builds ipal_map: NAME → CODE, which get_row applies (convert_table.py:47). Live confirmation on 4704: axis "Car type" → code CAR_SUBTYPE; "Engine Capacity" → ENGINE_CC; "Region" → PH_ADDRAREA. The name and the code routinely differ, and the grid must use the name.
QT_REQUIRED has a quiet pricing effect. Factors that are not QT_REQUIRED become pol_only (table_interpreter.py:120-126), and when isQuote='Y' every grid keyed on such a factor returns Value = 0.0 (table_utils.py:385-387) and validate_features stops demanding them (table_interpreter.py:209-210). A mis-flagged factor therefore makes a quote silently cheaper than the policy — with no warning.
HAS_CURRENCY='Y' marks insured values. set_currency_map (:803-821) intersects each cover's features plus its BP_BASE symbols with the IV set and asserts at most one IV per cover (:818), with a special de-duplication for IV_<OBJECT> (:814-815). It then expects a companion factor <IV>_CURRENCY in the request.
IPAL side — PR_PRICING_FACTORS. CalculationContextLoader.cs:69-73 loads it filtered on ProductCode and SaleStage (quotation vs policy), and RatingsEngineCaller.FormatFactors (:134-169) builds the outgoing dictionary, upper-casing every key and coercing by Datatype (ConvertValue:172-211). DURATION_DAYS/MONTHS/YEARS are injected unconditionally from the annex dates (:213-243). SyncProducts.check_and_create matches on pricingFactorCode + productCode (rating/ppa/sync_products.py:62) — one-way, additive, IPAL → ANLT.
What breaks when they disagree:
| | Mismatch | Symptom
| Grid axis or label prefix ≠ PRICING_FACTOR_NAME | deploy-time KeyError from ipal_map[f] / type_map[f] naming the axis (gen_rule_table.py:266, convert_table.py:47)
| Factor in a grid but missing from PR_PRICING_FACTORS for that sale stage | never reaches Abacus → AssertionError: Missing features {…} (table_interpreter.py:211)
| DATATYPE disagreement (e.g. numeric factor typed LIST) | wrong get_nodes branch → Cannot parse numerical label! at deploy, or a wrong bucket at runtime
| QT_REQUIRED='N' on a factor a quote needs | factor silently zeroed on quotes only
| Two HAS_CURRENCY='Y' factors on one cover | AssertionError: At most one IV can be defined per cover!
| <IV>_CURRENCY missing from factors | falls into the "no conversion" branch (:404-406), so a foreign-currency IV is priced as local
PPA_FACTORS not being version-scoped is worth calling out on its own: changing a factor's name or datatype affects every version of that product retroactively at the next genRuleTable, including templates you did not touch.
9. Offers
The engine knows nothing about offers. grep -rni "lb_offer|offer_code|lbrr" src/Analytics/riskmodel/ returns nothing. The offer layer is entirely IPAL/UI-side.
There is exactly one coupling point, and it is the template code:
LB_SEGMENTS ──┐
├─► LB_OFFERS (OFFER_CODE, BASIC_PRODUCT_ID, LB_OBJECT_NA_ID)
LB_OBJECT_NA ─┘ │
├─► LB_OFFER_COVERS (BASIC_COVER_ID, REC_EXTENT)
│ └─► LB_COVERS_DEF (VAR_CODE, VAR_DIM, AMNT, CURRENCY, RISK_CODE)
│ └─► LB_COVERS_DEF_RULES
└─► LB_OFFER_PRICING_RULE (TEMPLATE_CODE) ◄── the ONLY rating link
Which covers. LB_OFFER_COVERS names the basic covers in the offer; LB_COVERS_DEF carries their limits/deductibles as VarCode/VarDim/Amnt/Currency. PasPolicyCrud's offer services (Services/Custom/Offer/…, orchestrated by CommonOfferHelperService.GeneralGetOfferAlgorithm) materialise these into POL_COVERS / POL_COVER_DEF and set polObject.OfferCode = offer.OfferCode (CommonOfferHelperService.cs:790). getRates then receives selectedCovers = PolCovers.Where(Selected == 'Y') (RatingsEngineCaller.cs:50).
Which tariff. LB_OFFER_PRICING_RULE.TEMPLATE_CODE (LbOfferPricingRule.cs:8-10), read by TemplateSelector.cs:36-42; or, with no pricing rule, POL_OBJECTS.OFFER_CODE is used directly as the template code (RatingsEngineCaller.cs:46). That is the whole bridge — an offer influences pricing only by naming a PPA_PRODUCT_VERSIONS.TEMPLATE_CODE.
LBRR_* are not rating. LbrrChecks, LbrrCoverConflicts, LbrrCoverDependency, LbrrLimitRestrictions, LbrrClaimIndemnityDef are eligibility/validation rule tables consumed on the IPAL side; nothing in Analytics touches them.
Critical asymmetry for your agent: getRates prices every configured cover of the resolved version, not the selected ones. selectedCovers only affects (a) which covers policy-level LDs are applied to (table_interpreter.py:563-564, 616-620) and (b) the ('NAN', …) aggregate cover's BP (:577-593). Cover selection is applied by the caller, not the engine. A cover configured in ANLT but absent from the offer will still come back in rates[].
10. Things I could not establish from the code
- The exact SubstituteRegEx value that produces the _EUR template suffix. TemplateSelector.cs:47-61 implements s/pattern/replacement/, and git history (c316527a4c) confirms a _EUR mutation existed, but the configured value is operation-configuration data, not source.
- Whether 2MOL / 3MRC / 4MFL / 5MDS denote instalment counts, terms, or channels. Nothing in code or schema attaches semantics to a template code. Ask the product owner; do not infer from the string.
- The precise sympy failure mode for IV*BP(X) — I could not execute sympy (pip is sandboxed). The code path is read directly; the auto_symbol-produces-an-applied-undefined step is inferred from sympy's documented default transformations.
- PPA_PRODUCT_COVERS.GROUP_CODE may not exist (ORA-00904 where checked — confirm with ALL_TAB_COLUMNS before relying on it), yet set_cover_groups (table_interpreter.py:847-867) queries it unconditionally during gen_dict_table. Either that column is master-only and the deployed Analytics code is older, or regenerating a dict table would fail. Branch/schema drift — confirm per environment before relying on cover groups.
- P_WP_FORMULA and COMPLETE_RULESET_FILE on PPA_PRODUCT_VERSIONS are written by the CRUD layer and have no reader in the rating path.
Confidence
High — engine internals (§1–4, §7): read directly end-to-end, and every structural claim cross-checked against live data (version/template/filter mapping, BP_BASE values and CALC_ORDER for 4704, two real grid files, PPA_FACTORS name↔code divergence). Absence claims (PREMIUM_MIN ignored, ldRuleType/ruleItems/*_AMNT_* unread, engine has no offer awareness) rest on repo-wide grep for readers, plus, for PREMIUM_MIN, the independent live fact that no 4704 version points at post_filter_cap_floor.
Medium — §5.4 (business meaning of the template families) and §9 (the offer→policy materialisation path, which I traced structurally rather than exhaustively).
Low — the five items in §10.
Sources: rating/table_interpreter.py, rating/table_utils.py, rating/custom_interpreter.py, rating/deployment/{gen_rule_table,convert_table}.py, rating/ppa/{gen_formula,ppa_product_*,template_service,rates_overwriter,dict_table_overwriter,ld_rules_crud,sync_products}.py, rating/filters/**, api/common_api.py, gen_utils.py; PasPremium.Service/Services/PremiumEngine/{RatingsEngineCaller,CalculationContextLoader}.cs, Ablera.Serdica.Operations.PremiumCalculation/BeforePremiumCalculation/*.cs, Ablera.Serdica.DBModels.IPAL/DBModels/IPAL/Lb*.cs; live SRD_ANLT (PPA_PRODUCT_VERSIONS, PPA_PRODUCT_COVERS v5342, PPA_PRODUCT_FILES, PPA_FACTORS, CUSTOM_FILTERS); prior memory (BP_BASE single-BP() rule, 'NULL' literal, PRICING_FACTOR_NAME axes, filter authoring modes) — all re-verified here, with the "COVER_ID IS NULL → lead cover only" note flagged as not matching this branch.
analytics-ppaConflating them produces confidently wrong statements — the classic being "ABACUS updated the3,211 words
Analytics / ABACUS — SRD_ANLT PPA_*
Confirmed live: PPA_PRODUCT_COVER_LD and PPA_SHORT_RATE do not exist (ALL_OBJECTS returns no row for either); PPA_PRODUCT_LD exists in SRD_ANLT and carries COVER_ID, LD_RULE, LD_FORMULA, LD_RATING; LD_TYPE has three live values — LD, TAX and SR (short rate), ordinary loadings/discounts being much the largest. Cover-scoped LDs are the norm, not the exception — the large majority of rows carry a COVER_ID; confirm the current spread with SELECT LD_TYPE, COUNT(*), COUNT(COVER_ID) FROM SRD_ANLT.PPA_PRODUCT_LD GROUP BY LD_TYPE rather than trusting a figure written here. A guide that sends them to a separate table sends the majority of all LD configuration nowhere. Column lists beyond those named here are not individually catalog-checked. Verify against the catalogue before relying on them. This is where the premium is decided. IPAL decides the structure; this decides the number.
Three things share the "ABC/ABACUS" name. Keep them apart.
Conflating them produces confidently wrong statements — the classic being "ABACUS updated the policy", which it cannot do.
| | Name | What it is | Can it write policy data?
| ABACUS (the engine) | a stateless external Python REST premium calculator. JSON rating factors in, JSON premium out. It has no policy identity — it does not know a policy exists, it rates the input. | No. Never writes policy data.
| SRD_ANLT.PPA_* | the tariff configuration on the IPAL database — products, versions, covers, factors, rate grids, LDs. What this agent configures. | It is configuration, not policy data.
| ABC_ACCESS | an Oracle schema outside the Serdica databases holding policy-aware PL/SQL (PR_ABC_PROP, ABC_LIB_*, CFG_ABC_FLD_VALIDATION). It does UPDATE contract-side policy tables. | Yes — and it is outside this agent's scope (boundaries.md).
Write it correctly: premium calculation / rating / the JSON contract / getRates → ABACUS (engine). Any UPDATE on contract-side policy tables → ABC_ACCESS PL/SQL, never "ABACUS".
The seam: SRD_INTEGR.ABC_CFG_*
Between IPAL and the ABACUS engine sits a mapping layer on the IPAL database, in SRD_INTEGR — not in ABC_ACCESS, despite the ABC_ prefix. It decides what the engine is actually asked:
| | Table | Role
| ABC_CFG_PRICING_FACTORS | per product × object × cover: where each factor's value is sourced from, its default, and whether it needs mapping. ⚠ Its sources resolve over a database link to another system, and it feeds a different premium pipeline from the browser quote path — a product configured here carries no rows in it. See three-domains.md.
| ABC_CFG_PRICING_FACTORS_MAPPING_VALUES | external value → ABACUS value lookup, per factor
| ABC_CFG_PRICING_FACTORS_CODE_MAPPING | global per-code translations (gender, marital status …)
| ABC_CFG_COVERS_MAPPING | external cover type ↔ IPAL cover code, per product/object
| ABC_CFG_RESTRICTED_OVERRIDES | which operator-supplied overrides the payload will accept
| ABC_POLPR_PRICING_FACTORS_HEADER / ABC_POLPR_LOG | one row per ABACUS REST call — this is where you look to prove a call happened and what it carried
Consequences for configuring a product:
- ⚠ A factor rows into PR_PRICING_FACTORS and still may not reach the engine — but adding a row here is not the remedy. What decides whether a value arrives on the browser quote path is the factor's own POLICY_TABLE source and whether the wizard renders it at the quote step. A factor configured as a visible question the quote step never shows is never collected, and the quote fails naming a factor that is plainly present in the table. Compare the row's shape against a working product: source column, visibility per SALE_STAGE, datatype, and the form of its values.
- Values may need translating. A factor whose IPAL values differ from what the ruleset expects needs mapping rows, or the engine sees an unrecognised value.
- ABC_POLPR_LOG is the honest answer to "did the rate actually get calculated, and with what?"
⚠ AB_CFG_RATE_ABC_ON is not a per-product decision any more — all rows are ON. The real question for "is ABACUS authoritative for product X" is whether X is in PPA_PRODUCTS, not what the switch says.
⚠ Legacy TRFBST_<product>_* rate tables still exist elsewhere and are still queryable, but for ABACUS-managed products they no longer feed live pricing. Never quote them as current truth without first confirming PPA_PRODUCTS.
Mirror of IPAL
| | IPAL (structure) | Analytics (pricing)
| PR_BASIC_PRODUCTS | PPA_PRODUCTS
| PR_BASIC_COVERS | PPA_PRODUCT_COVERS
| PR_BASIC_LD | PPA_PRODUCT_LD (product- and cover-level)
| PR_PRICING_FACTORS | PPA_FACTORS
| PR_PRICING_FACTOR_VALUES | PPA_FACTOR_VALUES
| — | PPA_PRODUCT_VERSIONS
A sync operation imports the IPAL structure into Analytics: it creates the product and a version, and brings across covers and objects. It does not bring rating tables, LDs, or taxes — those are configured here, separately, and are the actual work.
The population question. "Is ABACUS authoritative for product X" is answered by whether X has a row in PPA_PRODUCTS — not by any on/off switch. (The historical AB_CFG_RATE_ABC_ON flag is ON for every product and the pre/post-premium hooks are NULL everywhere, so the switch carries no information.)
Version lifecycle
OPEN ──confirm──> CONFIRMED ──done──> DONE ──deploy──> DEPLOYED
^ |
└────────────────────── revert ─────────────────────────-┘
The column is VERSION_STAGE on PPA_PRODUCT_VERSIONS; the four values below are the whole enum.
| | Stage | Editable |
| OPEN | yes | active editing
| CONFIRMED | no | locked for review
| DONE | no | calculation ruleset generated
| DEPLOYED | no | live
Template vs version: a template is a named configuration variant (DEFAULT_TEMPLATE, STANDARD, PREMIUM); a version is a time-effective snapshot of one. A product may have several templates, each with several versions.
Practical consequence: you can only write to an OPEN version. If configuration "doesn't stick", check the stage before checking anything else.
Two dates, and they are not the same date
- PPA_PRODUCT_VERSIONS.EFFECTIVE_DATE — "these rates apply from". This is where a tariff's "in force from" belongs, and it is what getRates resolves a version against.
- PR_BASIC_PRODUCTS / PR_BASIC_COVERS / PR_BASIC_LD.VALID_FROM — "this exists in the catalogue". Live products separate them freely: 4704's covers are valid from 2020-01-01 while its rating versions carry dozens of effective dates.
⚠ Putting a future tariff date on the covers takes every cover out of force. The wizard reports 400 NoCoversFound:<id> at the Camunda task CalculatePremiumForObject and shows a 0.00 premium — and the premium is 0.00 because nothing was ever rated, not because of a rate or a currency. Check with SELECT ... FROM PR_BASIC_COVERS WHERE VALID_FROM <= :begin_date.
The quotation wizard hardcodes the policy start date to tomorrow, client-side, with no control to change it before the first Get Quote. So a product whose tariff starts in the future cannot be quoted through the UI until that date arrives — and the correct, loud failure is 400 "No template code found for product ID <p> with effective date before or at <d>". That is the configuration being right, not wrong; do not backdate the version to get past it.
⚠ SRD_INTEGR.ABC_POLPR_LOG is not being written on dev — zero rows in 60 days for any product Its emptiness is evidence about the table, not about whether a rating call happened.
Rating tables
Two shapes, and picking the wrong one is the usual mistake.
Single-row — one rate value
Use when a single value applies, either always or under a condition.
- Flat: 200 BGN for cover «COVER_A», always.
- Conditional: 200 BGN for «COVER_B» but only when «FACTOR_A» includes X and not Y.
Conditions are a list of rule items: {operator, factor, condition, value} where operator is null on the first item then and/or, and condition is one of =, !=, includes, !includes, <, >.
Multi-row — a rate grid
Use when the rate varies by factor combinations. A grid is: a row factor, a column factor, the value labels for each axis, and a CSV of rates.
,STANDARD,PREMIUM
<=10000,250,320
10001-20000,320,400
>20000,450,550
First cell empty; first row = column value labels; first column = row value labels; cells = rates. The labels must match the declared value labels exactly.
⚠ The factor codes must already exist as PpaFactor rows for that product. The sync creates them from IPAL pricing factors; anything the sync did not bring across has to be added before the grid can reference it. A grid referencing an absent factor is the most common rating-table failure.
Factor code vs value label — «FACTOR_IV» is the factor code, used for calculation; <=10000 is a value label, used as a CSV header. They are not interchangeable.
Rating-table filenames follow a convention: cover base premium {product}_{cover}_{version}, product LD {product}_{ld}_{version}, cover LD {product}_{cover}_{ld}_{version}.
Loadings, discounts and taxes
One table, not two. PPA_PRODUCT_LD holds both product-level and cover-level loadings; the scope is the COVER_ID column — null for product-level, set for a single cover, exactly mirroring PR_BASIC_LD.BASIC_COVER_ID on the IPAL side.
⚠ There is no PPA_PRODUCT_COVER_LD table. SRD_ANLT has exactly one LD table.
Verified columns of PPA_PRODUCT_LD: CORRECTION_ID, VERSION_ID, COVER_ID, LD_CODE, LD_NAME, LD_TYPE, LD_SIGN, LD_ORDER, LD_RATING, LD_RATE_DIM, LD_RATE_BASE, LD_FORMULA, LD_CURRENCY, LD_RULE, LD_MAX_LIMIT, MAXDF_EXEMPT.
| | Field | Values |
| ldType | LD, TAX, SR | SR = short rate — see below
| ldSign | 1, -1 | 1 increases, -1 decreases
| ldRateDim | P, V, R | percentage of base · fixed value · relativity multiplier
| ldRateBase | BP, IV | applied to base premium or to insured value
| ldOrder | number | calculation sequence, lower first
| ldRating | filename | the rating table, when the rate is not flat
| ldFormula | expression | when the rate is neither flat nor a table
| ldRule | SUM/MULT | per-LD, overriding the version default
| ldMaxLimit | number | cap on this LD's contribution
Short rate is an LD, not a table. There is no PPA_SHORT_RATE. A short-rate schedule is a row in PPA_PRODUCT_LD with LD_TYPE = 'SR', LD_CODE = 'PREM_SR', LD_RATE_DIM = 'P', LD_RATE_BASE = 'BP', and a rating-table filename in LD_RATING (convention {product}_{cover}_PREM_SR_{version}_ShortRate).
Scope every LD to a cover — a product-level LD reaches ONE cover
⚠ A PPA_PRODUCT_LD row with COVER_ID null is applied by the engine to the LEAD cover only. Confirmed by quoting a five-cover product whose five LDs (four discounts and a tax) were all product-level under LD_RULE = SUM: the discounts came back as a percentage of the first cover's base premium, the other four covers' WP equalled their BP untouched, and the tax was 2% of the lead cover's discounted premium instead of the policy's.
The compiled artefact is no help here — it carries the per-cover formula BP*(1 - d1 - d2 + l1) on every cover, so it reads as correct while the numbers are wrong. Only a quote shows it.
Write one PPA_PRODUCT_LD row per (cover, LD). Under SUM that reproduces a policy-level rule exactly, because the arithmetic is linear: Σ(BPᵢ × (1+s)) = (ΣBPᵢ) × (1+s). It is also what live data does — on dev every TAX and SR row on products 4703 and 4716 carries a COVER_ID; only an ordinary product-wide discount does not.
PREMIUM_MIN and PREMIUM_CAP are NOT read by the rating engine
⚠ They are columns on PPA_PRODUCT_COVERS and they look like a floor and a cap. genRuleTable does not compile them: SRD_ANLT.RATING_TABLE_FILES.CSV_TABLE contains no MIN or CAP token for any version, and a quote whose cover premium is 16.00 against PREMIUM_MIN = 40 returns 16.00.
A policy-level minimum premium is configured in the operations layer instead — a SRD_IPROD.PR_OPERATIONS row naming Ablera.Serdica.Operations.Confirmation.Bulstrad.LobLiability.MiniumPremiumPerPolicy (sic — the class name has the typo) with OPERATION_TYPE = 'POLICY_ISSUING', STAGE = 'UNDERWRITING' and CONFIGURATION = {"MinimumPremium": <n>, "Currency": "<ccy>"}. It runs at underwriting, which getRates never reaches, so a quote cannot verify it.
Order matters, and so does the version's rule
LDs apply in ldOrder; taxes normally last. LD_RULE decides how they combine — it exists on both PPA_PRODUCT_VERSIONS (the default for the version) and on each PPA_PRODUCT_LD row (an override for that one LD). PPA_PRODUCT_VERSIONS also carries a separate TAX_RULE. So "check the version's ld_rule" is necessary but not sufficient — check the LD row too:
SUM — add the percentages, apply once: BP=100, +10%, −5% → 100 × 1.05 = 105 MULT — apply sequentially: 100 × 1.10 = 110, then × 0.95 = 104.50
Same inputs, different answer. When a premium is off by a small amount and everything "looks right", check ld_rule before checking the rates.
Cover premium configuration
| | Field |
| bpRateDim | P = % of insured value · V = fixed · R = relativity
| bpBase | «FACTOR_IV», null, or a formula
| coverBpRating | rating table filename
| premiumMin / premiumCap | floor and cap
| calcOrder | sequence, when one cover's premium depends on another's
Also on PPA_PRODUCT_COVERS and easy to miss, because they are underwriting limits rather than rates: BP_SHIFT, C_WP_FORMULA, PREM_CURRENCY, DISCOUNT_AMNT_MAX / DISCOUNT_PRC_MAX (the most that may be discounted off this cover) and RENEWAL_AMNT_CAP / RENEWAL_PRC_CAP (how much the premium may move at renewal).
Non-linear rules
Not every rule fits a grid. Where a rule cannot be expressed as a linear formula — the case Veselin the platform uses additional filters implemented as code, rather than one enormous table.
Filters are out of scope for now (see boundaries.md). If a product needs one, model it, say so in MISSING.md, and stop at the boundary rather than inventing a grid that approximates it.
BP_RATE_DIM and BP_BASE — what the engine actually accepts
Building a tariff from documents and reconciling a quote against it. to the cent.
BP_RATE_DIM = 'P' is a PERCENT of BP_BASE: premium = base x rate / 100. A tariff quoted in per-mille must therefore be entered as r/10, which gives limit x (r/10)/100 = limit x r/1000 — the formula such tariffs state. Getting this backwards is a silent factor-of-10 or 100, and no structural check catches it; only a reconciled quote does.
BP_BASE accepts arithmetic on factors, and exactly ONE BP(<COVER>) term. Fine: IV_LIABILITY, LIMIT_AG_X*0.5, IV*NUM_INS_OBJ, Max((IV_HOME-10000),0), BP(CA_FULL). Not fine: BP(A)+BP(B). genRuleTable answers "Unrecognized cover A)+BP(B" — it takes everything between the first BP( and the last ) as one cover code. Verified twice: by that error, and by a query over every live PPA_PRODUCT_COVERS row on dev, which found no product with more than one BP() term or any arithmetic around one.
C_WP_FORMULA does not rescue this — its BP is the cover's own base premium, not another's. So "X % of the total base premium across several covers" has no configuration expression. Model it, say so in MISSING.md, and configure the cover as manual (BP_RATE_DIM='V', BP_BASE='NULL') rather than approximating it with one cover's premium.
Byte semantics bite on Cyrillic. PPA_PRODUCT_COVERS.COVER_NAME and PPA_PRODUCT_LD.LD_NAME are VARCHAR2(100) in byte semantics; Cyrillic is two bytes a character, so a 54-character Bulgarian name fails ORA-12899 at 108 bytes. These are lookup keys, not captions — put the code there. The operator-facing text belongs in IPAL on PR_BASIC_COVER_NAMES -> SR_MESSAGES_LNG.
Column traps when building a product from nothing
- PPA_PRODUCT_LD.LD_CODE is VARCHAR2(20). A longer code fails with ORA-12899 naming the column. TAX_INSURANCE_PREMIUM is 21 characters and does not fit.
- PPA_PRODUCT_COVERS.BP_BASE must be populated even when the cover takes no base. The convention is the literal 4-character string 'NULL', not a real null — 5 973 live rows carry it against 399 true nulls. genRuleTable refuses a true null with "'BP_BASE' should be populated!". Fixed-value covers (BP_RATE_DIM='V') are the usual case.
- BP_BASE accepts an expression, which is how a cover whose sum insured is derived from another's is configured: IV_PERS*0.2 for "20% of the sum insured". Live precedents include IV_SEAT*SEATS and BP(CA_FULL).
- ppaProductVersionsCreate silently drops ldRule. taxRule lands from the same payload; ldRule arrives null. genRuleTable then refuses with "'LD_RULE' should be in ('SUM','MULT')" — a message that names the column and reads like a bad value when the value is absent. Set it with ppaProductVersionsUpdate after creating, and check the row before compiling.
- Analytics *Update commands are not merges. The handler indexes columns straight out of the request, so a partial payload is a KeyError naming one key at a time. Read the row back, camelCase every column, change what should differ, and send the whole thing — plus id.
Writing PPA rows directly — the things that reject you
Each of these costs a round trip if you do not know it.
PPA_PRODUCTS.PRODUCT_ID is NOT NULL and has no sequence. Nothing generates it — supply it yourself. The current convention is PRODUCT_ID = PRODUCT_CODE; older rows carry small sequential ids from before that. By contrast PPA_PRODUCT_VERSIONS.VERSION_ID is sequence-backed (PRICING_PRODUCT_VERSIONS_SEQ) — never supply that one.
PPA_PRODUCT_VERSIONS has no VALID_FROM. The date column is EFFECTIVE_DATE, and it is what getRates resolves a version against. Its required columns are PRODUCT_ID, VERSION_STAGE, OPENED_ON, TEMPLATE_CODE.
BP_RATE_DIM is constrained to P, V, M — percentage, value, multiplier. Anything else fails PPA_PRODUCT_COVERS_RATE_DIM_CK.
⚠ For a flat amount (BP_RATE_DIM = 'V'), BP_BASE is the literal string 'NULL' — four characters, not a SQL NULL. It is the single most common combination in live data. Leave the column actually null and genRuleTable refuses the whole version with "'BP_BASE' should be populated!", which reads like a missing rate and is not.
For BP_RATE_DIM = 'P' the base names what the percentage applies to — a factor code such as IV, or another cover's premium as BP(<COVER_CODE>).
genRuleTable validates before it compiles, and it validates the whole version. A single cover with a bad BP_BASE blocks every cover. Fix the row, return the version to CONFIRMED, and recompile — a DEPLOYED version will not compile.
Filters — where the rules a grid cannot hold
A grid is a pure function: factors in, one number out, per cover. Anything that needs to see the assembled result across covers is a filter, registered on the version.
The domain owners put it plainly: "there are rules that are non-linear — you cannot code them with a linear formula. If you start building trees with min/maxes the formula becomes enormous. That is why we add these extra filters." Motor casco is their canonical example.
What real filters do, that a grid cannot:
- derive a factor the request did not supply, from another field;
- clamp an override to a permitted range whatever was asked for;
- apply a cross-cover minimum premium, writing the shortfall into a correction row and scaling the exempt loadings and taxes proportionally;
- rebase a cover on another cover's written premium — a grid can only reach another cover's base premium;
- recompute one loading as compounding inside an otherwise additive product.
⚠ A policy minimum premium is therefore a FILTER concern, not a column. PREMIUM_MIN on the cover has no reader in the rating path — a quote will happily return less than it. A document that states a minimum premium needs a filter, and writing one is a code change: that is a deploy boundary (boundaries.md), not something to configure around.
boundariesKnowing where to stop is part of doing the job. Each of these is a real thing that exists; none of1,108 words
Boundaries — what is not yours
Knowing where to stop is part of doing the job. Each of these is a real thing that exists; none of them is this agent's to change today. When work runs into one, stop at the line, say what you found, and record it.
Not configured here
Core contract configuration — conditions, covers, questionnaires, print documents and the rest of the contract-side nomenclature. A product exists there too, and a fully working product needs both sides. This agent configures the IPAL / ABACUS / offers side. If the contract side is missing, that is a finding to report, not a task to take on. → three-domains.md for the detail.
The CFG_MAPPING_* seam — how IPAL codes translate onward. Read it to understand; do not write it.
Custom "filter" code — non-linear rules that cannot be expressed as a rate grid are implemented as code, referenced from PPA_PRODUCT_VERSIONS.CUSTOM_FILTER_ID.
⚠ Scope changed on operator instruction. This was deferred ("vanilla first"); the operator has since said filters are in scope and must be understood and exercised. Until the backend sweep lands and knowledge/ carries a real account of how a filter is authored, registered and evaluated: model the rule, describe it in prose, flag it — but treat "I don't know how filters work" as a gap to close, not a boundary to respect.
Date and period cross-checks — the class of validation raised as "the next stage". Not part of the configurator now.
Any environment other than dev — dev is the only one this workspace reaches, on either path, and that is enforced rather than conventional. If a task appears to need another one, say what you needed it for and stop; enabling one is an operator decision.
The deploy boundary — the question that decides most jobs
Most of the hard calls in this work are one question wearing different clothes:
Is this inside configuration, or does it need someone to deploy something?
Both answers are legitimate outcomes. Reaching "this cannot be done as configuration, here is what must be deployed and who owns it" — with the evidence — is a successful result, not a failure. There are only two ways to get this wrong:
- forcing a bad fit to reach a green result — picking a process, component or code that almost matches so the run "completes". A product that opens and then fails is worse than one honestly reported as blocked, because the failure surfaces later and on someone else's time;
- stalling because you did not realise asking was allowed.
The recurring cases
Each is settled the same way — establish it from the RUNNING system, early, before the work that depends on it:
| | Question | How to settle it | If the answer is no
| Can I reuse a process, or is a new one needed? | start each candidate and read what comes back — see making-a-product-issuable.md | a process is a backend deploy
| Does the process's task chain fit the product's shape? | complete the first task, not just render the step | backend
| Is this object code renderable by the UI? | does a live product use it in a working flow? | a frontend change
| Can this lookup code be created from configuration? | is there a write path for that lookup? | another service, or a DBA
| Can this pricing rule be expressed as a grid? | can the engine's own expression syntax carry it? | a custom filter, or backend
| Does the wizard need a component that does not exist? | is the component key registered in the frontend? | a frontend deploy
Settle it early, and you can ask
These checks are cheap — a handful of calls — and each one decides whether the rest of the work can land. Running them after building is how a correct configuration ends up stranded at the last step.
You may delegate to the specialists for the ones that turn on their code: backend for process, task-chain and engine questions, frontend for component and rendering questions. They are read-only and they own that knowledge — a question answered from their repo beats an inference from yours. Delegation is the intended route, not an escalation.
Reporting one
Name the boundary, the evidence that established it, exactly what would unblock it, and who owns that. "Needs a Camunda process deployed for this product's shape; keys tried and what each returned; backend owns it" is a finished piece of work. So is a configured product. What is never finished is a green result that hides one of these.
Not this agent's job at all
Policies, claims, premium collection, the conversational desk, ServiceDesk tickets. Adjacent, and other agents own them. Say so and stop.
When you hit a boundary
Report it in the same shape as missing information: what you hit, why it blocks (or does not), who or what owns it, and what you did up to the line. A clean stop with a clear handover is a successful outcome.
Before you hand something back as someone else's defect
A boundary report is a claim about their code. Gate it: find a product that works today and check whether it succeeds through the same path. If one does, the difference is yours.
State in the report which working products you checked, what they carry, and how your product differs. A report that cannot name a counterexample has not established a defect — it has established that one configuration fails, which is a different and much more common thing.
A factor's source must resolve locally — a boundary in both directions
Reporting on another system's data is not this agent's job, and neither is depending on one. A product's configuration must resolve its values inside the Serdica schemas. If the only source you can find for a factor resolves over a database link to another system, that is not a source you may use — it is a finding to raise, with the evidence of where it resolves.
SRD_INTEGR.ABC_CFG_PRICING_FACTORS is the mapping whose sources resolve that way: a product configured here should carry no rows in it. The local equivalent is PR_PRICING_FACTORS.POLICY_TABLE / VALUE_SOURCE, which name stores that exist in SRD_CUST.
Do not pin a constant in place of a blocked source either: a constant standing in for a customer-selected value mis-prices every non-default choice, silently.
See three-domains.md for the three queries that establish where a source actually resolves.
configuration-orderA row cannot reference something that does not exist yet. Lookup codes come before the rows that use833 words
The order of work
A 12-step process. The dependency part of it is real. The sequence part is a guideline — route to the minimum a request needs.
The dependency chain — this part is not negotiable
A row cannot reference something that does not exist yet. Lookup codes come before the rows that use them; parents before children.
LT_* → PR_BASIC_PRODUCTS → covers → definitions / risks / events
→ LDs · documents · annexes
→ PR_PRICING_FACTORS → _VALUES → _DEPENDENT
→ PPA_* (sync, then rating tables + LDs)
→ LB_* offers → LBRR_* restrictions
The steps
| | # | Step | Table | Guide
| 1 | product | PR_BASIC_PRODUCTS | ipal-configuration.md
| 2 | names | PR_BASIC_PRODUCT_NAMES (+ SR_MESSAGES) |
| 3 | objects | LT_OBJECT_CODE → PR_PRICING_FACTOR_VALUES [OBJECTS_CONFIG] |
| 4 | covers | LT_COVER_CODE → PR_BASIC_COVERS → PR_BASIC_COVER_NAMES |
| 5 | definitions | LT_VARIABLES → PR_BASIC_COVER_DEF | limits + deductibles live here
| 6 | risks | LT_RISK_CODE → PR_BASIC_COVER_RISKS |
| 7 | events | LT_EVENT_CODE → PR_BASIC_COVER_EVENTS |
| 8 | LDs and taxes | LT_LD_CODES → PR_BASIC_LD |
| 9 | documents | LT_DOC_CODES → PR_BASIC_PRODUCT_DOCUMENTS | plural
| 10 | annexes | PR_ANNEX_TYPES (+ PR_ANNEX_FACTORS) | keyed on PRODUCT_CODE; no lookup FK
| 11 | pricing factors | PR_PRICING_FACTOR* | pricing-factors.md
| 12 | offers | LB_*, LBRR_* | offers-engine.md
| — | rating | sync to PPA_*, then rating tables, LDs, version stage | analytics-ppa.md
| — | visibility | SRD_SYS."Routes", roles | ipal-configuration.md
Steps 1–10 are one coherent unit of work; 11, the rating configuration, and 12 are separable.
Routing — do the minimum
Inherited from the previous implementation and still good. Most requests are not a full product build.
| | Request | Touch
| "change the premium rate for «COVER_A»" | rating only
| "add cover THEFT to product 5000" | IPAL covers only
| "copy pricing factors from «SOURCE_PRODUCT» to «TARGET_PRODUCT»" | pricing factors only
| "create a product with covers and rates" | IPAL → rating
| "full product setup from this document" | everything, in order
| "why doesn't cover X appear for this customer?" | nothing — investigate: offers, restrictions, roles, dependent factor lists
Routing to everything every time is slow and creates changes nobody asked for.
Copy from a similar product first
The fastest correct path to a new product is usually clone and adjust, not build from nothing: it inherits decisions that were already reviewed, and it keeps naming consistent with its neighbours.
Order for a clone: product → covers → cover children (using the returned ID mapping to re-parent) → LDs → factors → factor values → dependencies → offers. Each copy returns a source→target ID mapping; the children are copied against that, not against the original IDs.
Before cloning, confirm the source product is actually a good template — that it is in the ABACUS + IPAL bucket (three-domains.md) and that it is the shape you want, not merely the nearest name.
Where things usually go wrong
| | Symptom | Look at
| dropdown is empty | dependent-factor rows missing → the factor "does not apply"
| a limit cannot be selected | PR_BASIC_COVER_DEF range or AMNT_LIST, not the pricing factor
| the product is invisible to an operator | Routes at every ancestor level, AllowedRoles, LT_USER_ROLES, ATTR3 on values
| configuration "doesn't stick" in rating | the PPA version is not OPEN
| a rating table will not save | the factor code has no PpaFactor row for this product
| the premium is close but wrong | version ld_rule — SUM vs MULT — and ldOrder
| the document type is missing in the UI | PR_BASIC_PRODUCT_DOCUMENTS is empty for that product
Two dates that are easy to conflate — and the failure is silent
| | Column | Means | Comes from
| rating version EFFECTIVE_DATE | when this tariff applies | the tariff document — "in force from …"
| product / cover VALID_FROM | when the thing exists in the catalogue | usually NO document source — you decide
A tariff's future in-force date belongs on the rating version. Put it on the covers instead and every cover is not-yet-in-force: the quote fails finding no covers, while the rating side looks perfect.
Observed: zero of five covers in force at one date, all five at a later one — the same configuration, two different quote dates. Check with a count of covers in force at the date you intend to quote.
Catalogue availability usually has no source in the documents at all. That makes it a decision, so record it as one rather than borrowing the tariff's date by default.
discovering-a-productYou do not know what this product is. Neither does anyone who hands you a document. Find out.701 words
Discovering what a product is
Replaces an earlier discovering-a-product.md that described motor, property, cargo, liability, travel and health and what each "implies". That file was wrong in kind, not in detail: an agent that already knows what a products of that kind looks like stops reading the document in front of it, and is confidently wrong the first time a product does not fit. This file describes how to find out instead.
You do not know what this product is. Neither does anyone who hands you a document. Find out.
The two sources, in order
1. The document is the specification. It is the only statement of what this product is meant to be. Read it before you look at anything in the database, so what you find there cannot colour what you think the document says.
2. The live estate is the precedent. Whatever this product turns out to be, something like it has almost certainly been configured before, and that configuration encodes decisions someone already got right. Find the nearest neighbours and read them.
How to find the neighbours
You have SQL. Ask the system what shapes exist rather than assuming a taxonomy:
- What is the object structure of products that resemble this one — one object or several, and what are the status rules between them?
- Are covers independent perils, or levels/variants of one thing? Look at whether cover codes repeat per object, and whether the product joins on cover order rather than cover code.
- Is there an insured value at all, and is it per policy or per cover?
- What factors do comparable products rate on, and which of those are dependent on others?
- How is the tariff shaped — a flat rate, a conditional rate, or a grid, and over how many axes?
- Are there relationships between covers — dependencies, exclusions, limits expressed as a fraction of another cover?
Every one of those is a query, not a recollection.
Ask first whether the product is already there
Before anything else: query PR_BASIC_PRODUCTS for the code on the document. If a row comes back, you are looking at a revision, and the interesting question changes from what is this product to what is the delta. Export it (export_product.py), map the document's entities onto the live ones, and let the unmatched elements be the answer. The elements the document has and the live product does not are usually the entire point of the document.
Then say what you found
State the shape you observe, the products you compared against, and the ways this one differs from its neighbours. The differences are the interesting part — they are where the document is telling you something the precedent cannot.
If nothing in the estate resembles it, say that plainly and describe the shape on its own terms. A product with no precedent is a normal event, not an error.
What not to do
Do not name a category and then configure the category. "This is a products of that kind, so it needs a vehicle object and these factors" is how you end up configuring a product nobody asked for. Categories are a summary of what you found, never a substitute for finding it.
Do not import structure from a neighbour without checking it against the document. Copying a similar product is the fastest correct start (configuration-order.md) and the fastest way to inherit something irrelevant. Every copied element must be justified by the document or removed.
Do not assume the document uses the platform's vocabulary. It will say what the business calls things. Mapping business language to configuration is your job, and where the mapping is uncertain it belongs in MISSING.md, not in a guess.
Record what you learn
When you work out a shape that was not obvious — a family of products that share an unusual structure, a convention in how a domain is configured — write it down as an observed pattern with the products you saw it in, dated. That is a finding. It is not a rule, and the next product is still allowed to break it.
environmentsenvironment this workspace can reach on either path.529 words
Environments and access
Names and roles only. No credentials, hosts, or connection strings in this file — those live in the environment and in the gate scripts' configuration. If you need a connection detail, get it from the scripts' --help or from memory (mcp__memory__recall), not from a document.
The layers you can reach
| | Layer | Through | Mode
| Oracle — IPAL (SRD_IPROD, SRD_SYS, SRD_INTEGR, SRD_PR), Analytics (SRD_ANLT) | scripts/pcmcp.py (MCP) | scoped by the MCP instance: dev only, DDL blocked; DELETE permitted on writable tables
| Backend source | serdica-backend | read
| Frontend source | serdica-ui | read
| The running UI | browser | read; use it to verify
Environments
There is one: dev. It is the target for every read and every write, and it is the only environment this workspace can reach on either path.
This is not a convention to be weighed against convenience — it is enforced in two independent places, neither of which a task can widen:
| | Path | Gate
| Database | the scoped Oracle MCP instance is configured dev only; any other environment is refused before a connection is opened
| Gateway / rating | pcgw.check_environment refuses anything but dev before a request is built, and no other environment has a URL or credentials
So there is no environment decision to make. Nothing needs comparing across environments, no configuration is "promoted" from here, and an absent feature is absent — not a sign you are pointed at the wrong place.
If a task appears to require another environment, that is a question for the operator, not something to route around. Say what you needed it for and stop.
Business lines
bulstrad_insurance and ablera_insurance are configuration lines, passed as a parameter — the same workspace serves both. Which one a task means is not something to guess: if the request does not say and it changes the outcome, ask.
Two configuration paths, not one
Do not assume that reaching Oracle means you can reach the gateway.
| | Path | Used by | Configured from
| Oracle | pcmcp.py, export_product.py | none — the workspace holds no database credentials; PC_ORACLE_MCP_URL only
| Gateway / rating | abacus.py (via pcgw.py) | PC_GATEWAY_URL, PC_IDENTITY_ISSUER or PC_TOKEN, or the dev entry in .pc-endpoints.json at the workspace root
.pc-endpoints.json is gitignored and holds the dev entry only. A clean workspace therefore has read access to the dev database and no gateway access at all. That is the expected state of a fresh checkout, not a fault: ask for the values rather than deriving a URL from SERDICA_UI_<ENV>_URL, which is the UI host and not necessarily the gateway.
Access, honestly
Service-account access for the write path is unresolved at the time of writing — the previous implementation ran under a personal session, which is precisely what the team asked to fix. The read path does not depend on it. If a write fails on authentication, that is the known gap, not a new bug: say so rather than working around it.
input-contractTerms usually win on what is covered, the tariff on numbers, requirements on who sees what.3,235 words
The input contract — what the sources are and how to read them
Built from real product templates held in tests/fixtures/. The sheet names and layouts below are what those files actually contain — but templates get revised and they differ between products, so read the outline of the workbook in front of you before trusting this map. The quirks recorded here (an optional Име на покритие row, cross-sheet cover counts that disagree, an ambiguous /, a stale Ref block, a header-only deductible block) are things that occur, not things that always occur.
Source authority
| | Source | Decides | Does not decide
| Product template (Excel) | structure — covers, limits, discounts, taxes, premium rules, underwriting questions | rate values, source-code-level behaviour
| Tariff files | rate grids, layers, base premiums, percentages, min/cap | what a cover is called, whether it is optional
| General terms (общи условия) | what a cover covers — events, risks, exclusions | anything about the UI or the rating engine
| Customer requirements | visibility, roles, segments, offer packaging, process rules | the tariff
When two sources disagree, do not silently pick. Say which two, quote both, propose a reading. Terms usually win on what is covered, the tariff on numbers, requirements on who sees what.
The the operator product template — 11 sheets
| | Sheet | Holds | Layout
| Промени_документ | document change log — versions, authors, dates | rows
| Продукт | product parameters: code, names (system + local), insurance type, product type, payment type, currencies, country, client type, min/max term, effective-date rules, offer validity, cancellation, renewal | parameter / value / comment, in sections
| Покрития | the covers | transposed — see below
| ЗС, Премийни лимити | three stacked tables plus a legend: premium instalment limits, sum-insured / liability limits, then other limits and deductibles | rows, per cover code
| Правила за премия | per cover: net-premium formula, other-premium formula, gross-premium formula, comment, tariff-table reference; then a policy-level minimum premium | rows, per cover code
| Подписвачески документи | required underwriting documents | rows
| Подписвачески въпросник | underwriting questions: question code, text, answer type, answer codes, default, linked questions, conditions | rows
| Отстъпки_Завишения | discounts and loadings | rows, numbered #
| Такси и приложими налози | taxes and fees | rows, numbered #
| Общи и специфични условия | the general and specific conditions text | rows
| Ref | allowed-value enumerations — see below | blocks
Покрития is transposed. Read this before counting anything.
Covers are columns, not rows. Row 3 numbers them 1 … N; each subsequent row is an attribute:
row 3 ПОКРИТИЯ 1 2 3 …
row 4 Код покритие «COVER_1» «COVER_2» «COVER_3»
row 5 Тип покритие Задължително Задължително Опционално
row 6 Име на покритие «COVER_1» «COVER_2» «COVER_3»
row 7 Име на покритие на местен език «Bulgarian cover name»
row 8 Застрахователна сума/Лимит «limit rule, often relative to another cover»
row 9 Изчисление на застрахователна сума Ръчно въвеждане | …
row 10 Тип изчисление на премия Тарифа | Ръчно | …
row 11 Забележка «notes, including manual-premium allowances»
Count covers by counting the numbered columns in row 3, not by counting rows. Reading this sheet row-wise is the most direct route to the miscount this agent is known for.
⚠ The attribute rows are not at fixed row numbers — Име на покритие is optional. The map above is one real layout, not the layout. A second shape seen in practice: the Име на покритие row is absent, so every row from 6 down sits one higher — r6 is the local-language name, r7 the limit, r8 the SI calculation, r9 the premium calculation, r10 the note.
Reading that file by the row numbers above takes the limit text as the cover's name, yielding covers called "Избраната застрахователна сума" and "До 20% от ЗС по DEATH_ACC". Nothing errors; the model is just wrong.
Match the attribute rows by their label in column A, never by row number. The labels are stable across templates; their positions are not. And when Име на покритие is missing, that is not a detail to work around — the workbook is supplying no English cover names at all, which is an unresolved (translations.md), not something to fill from the code.
⚠ Покрития is not authoritative on its own — cross-count it against the other three cover-keyed sheets. ЗС, Премийни лимити (both blocks), Правила за премия and Такси и приложими налози each repeat the full cover list, in the same order, one row per cover. Seen in a real template: Покрития numbered 12 columns while all three other sheets listed 13 rows, the extra one sitting second in every list and appearing nowhere on Покрития. Three independent sheets outvote one. Take the union, model the extra cover, and record that its Покрития-only attributes — cover type, SI calculation, premium calculation, notes — have no source at all.
⚠ The / in a cover code does not always mean the same thing. The row-12 footnote (* код/име на покритие при групова полица) explains it as individual/group — and for most columns it is. In the same file, other columns used / for a commercial variant instead (one-off vs subscription), marked only by a -абонаментна suffix on the Име на покритие row and spelled out in the Коментар of ЗС, Премийни лимити. Applying the footnote to every column mislabels those. The * marker is the tell — but it is applied inconsistently, so read the per-column comment too.
The individual/group / split is usually the object fan-out, not two cover codes. A product whose codes pair as X / XG typically exists live as ONE cover code over two objects (observed: PERSON and GRPPA on a personal-accident product). Modelling each side of the / as its own cover doubles the cover count.
Three more things this sheet carries that are easy to lose:
- Тип покритие mixes languages. The same file uses Задължително/Опционално and Optional in adjacent columns. Задължително → COV; Опционално/Optional → OPT.
- Застрахователна сума/Лимит is often relative — "maximum the limit under Clause A", "up to 10% of the sum insured of the immovable property". Those are limit restrictions (offers-engine.md — LBRR_LIMIT_RESTRICTIONS, types EXC/EQ/FALL), not a number to type into a range. Model them as relationships.
- Име на покритие frequently repeats the code, with the real label only in Име на покритие на местен език. Do not take a code as a name.
ЗС, Премийни лимити holds three tables and a value legend in one sheet
Each block repeats the same cover codes in the same order, separated by blank rows:
| | Block | Header row says | Columns
| 1 | Премийни лимити | cover · currency · type · instalment count · min · max · comment
| 2 | Застрахователна сума / Лимит на отговорност | cover · currency · type · min · max · comment
| 3 | Други лимити и самоучастия | cover · risk · limits · limit type · deductible · deductible type · application rule
Then, far below the data and offset into the middle columns, two legend blocks — the allowed limit types, and the allowed deductible application rules with their English definitions. They are reference material, not product data; reading them as rows invents entities that do not exist.
⚠ Block 3 is frequently header-only. Observed: the Други лимити и самоучастия header row present, every data row beneath it blank, and the two legend blocks (limit types; ~13 deductible application rules with English definitions) sitting far below in the middle columns. That product had no deductibles. Reading the legends as data invents a dozen rules that do not exist.
Block 3 is where deductibles live, and it is the one most easily lost — it sits after several blank rows and its Тип лимит/Тип самоучастие columns are frequently empty even when the deductible text is not.
A sheet is not a table. Find every header row before reading data, or a later block reads as a continuation of an earlier one — and the limits end up on the wrong entity.
Отстъпки_Завишения and Такси и приложими налози — same 10 columns
# · Тип · Наименование · Автоматично/Ръчно · Приложимо ниво · Приложима основа · Допълнителна опция · Стойност/Формула · Приложимо условие · Референция към тарифна таблица
Mapping into the model:
| | Column | Model
| Тип = Отстъпка | type: LD, sign: -1
| Тип = Завишаване | type: LD, sign: 1
| Тип = Tax | type: TAX, sign: 1
| (the taxes sheet) Наименование = a cover code | one tax, N cover scopes — not N taxes
| Приложимо ниво = Полица / Покритие | product-level / cover-level scope
| Приложима основа = Премия | rate_base: BP
| Допълнителна опция = % | rate_dim: P
| Автоматично/Ръчно | automatic vs operator-applied
| Референция към тарифна таблица | the legend, per row — which tariff table backs this rate
⚠ The # column over-counts, and it repeats itself. Rows in that numbering are not all loadings or discounts — and the numbering is not even unique: one observed template used 14 twice. Placeholder rows carrying only a # and a Тип are empty, not entities; in the same file five of the six Завишаване rows were blank, meaning the product had no loadings at all. Count rows that have a Наименование, a Приложимо ниво, an основа and a стойност. In one observed template, #12 is "Общ размер на приложимите отстъпки от 1…" — an aggregate cap on the total discount — and #13 is "Допълнителна отстъпка по компетенции", a discretionary authority-based discount (0–50% in 5% steps). Neither is an ordinary LD row.
⚠ Приложимо условие carries mutual exclusions between rows — "only one of 7, 8, 9, 10 applies", "if this applies then #6 does not". That is real business logic. It must reach the model; it does not fit in a single LD row, and dropping it is a silent behaviour change.
Правила за премия → the tariff seam
Per cover: how net premium is computed (Тарифа = from a rating table, or a manual absolute/%), whether another or gross premium formula applies, and Референция към тарифна таблица. The policy-level minimum premium sits at the bottom of the same sheet.
When it says "в тарифата са включени стандартните отстъпки" — the standard discounts are already inside the tariff — those discounts must not also be configured as separate LDs. Applying them twice is a quiet pricing error that no structural check will catch.
Подписвачески въпросник → pricing factors
⚠ Count distinct Код въпрос values, not rows. Each answer option gets its own row with a blank question code, so a 36-row sheet can be 17 questions. And the numbering has gaps: one real template ran 1…19 with 5 and 13 absent, so the author's own count and the sheet's contents disagree. Model what is written and record the gaps.
⚠ Дефолтна стойност is a per-answer flag (Да/Не), not a value. It marks which of the question's answer rows is the default. On numeric questions the answer row's Текст отговор holds the number and Дефолтна стойност = Да marks it — so the default value is in the answer column.
⚠ Two adjacent questions can be one factor. A question re-asked for a different segment ("…за спортисти") with one shared answer and one new one is one factor with the union of the answers, dependent on whatever selects the segment. Modelling them separately creates a factor the platform does not have.
The underwriting questions are the operator-facing inputs, i.e. pricing factors. Тип отговор maps to the factor datatype:
| | Тип отговор | datatype
| Number | NUMBER
| Date | DATE
| Text | TEXT
| LOV | LIST (with its answer codes as values)
Код на свързани въпроси is a factor dependency (PR_PRICING_FACTOR_DEPENDENT), and Условия often says the tariff changes with the answer — which makes the factor SALE_STAGE = POL and a rating key, not a display-only prompt.
⚠ The sheet can declare itself empty and still carry a rating factor. A first row reading „няма въпросник” — there is no questionnaire — followed by rows that describe how the tariff varies with the answer is a real shape, not a contradiction to resolve by believing the first row. Such rows typically have no Код въпрос and no Код отговор, and Тип отговор says Text where the content is plainly a short enumeration. Model the factor with a null code and record the missing codes; do not drop it, and do not invent the code.
Rating factors also hide outside this sheet. Покрития!Забележка carries lines such as „тарифното число зависи от земетръсната зона” — a rating key that appears in no questionnaire, in no factor list, and with no value enumeration anywhere in the workbook. Read the per-cover notes for these before concluding a product has no factors.
Ref — a value legend, not a file legend
⚠ Ref is often stale. In one real template the entire Insurance_Type block contained a single value, MTPL, in a personal-accident product whose Продукт sheet said Лично застраховане — the block was an unedited leftover from a motor template. A Ref block that does not contain the value you are validating is evidence about Ref, not about the product.
Blocks of allowed values: Payment_Type, Insurance_Type, Product_Type, Entity_Type, Age_Rule, Answer_Type, Discount_Type, Charge_Type. Use it to validate what you read from the other sheets, and to resolve prefixed values (3-Еднократно и на вноски).
The legend, and the gap in it
Vladimir's requirement was a legend mapping covers and rates to the files where they are defined, and to the symbols used inside those files:
"Ако имаш примерно десет тарифни файла — можеш ли да разбереш от Excel-а, без да имаш тази референция, за кой са?"
What the template has today: a per-row Референция към тарифна таблица in the discounts, taxes, and premium-rules sheets. What it does not have: a sheet mapping tariff file names to covers, or the symbol/label names used inside those files.
So: if there is more than one tariff file and nothing resolves which cover each belongs to, that is a blocking unresolved. Do not infer it from file names. Ask.
The second template shape
Product_HomeShield_*.xlsx is a different, English, one-sheet-per-domain layout — Product, Covers, Cover Definitions, Pricing Factors, Tariff, Discounts & Loadings, Taxes & Fees, Offers — row-per-entity throughout. It maps almost directly onto the model and is the easier case.
Detect the shape before reading. Bulgarian sheet names and a transposed Покрития mean the real the insurer template; English domain sheets mean the other one. Assuming the wrong one produces confident nonsense.
Reading them
scripts/read_sources.py <dir> for an inventory, then --sheet <name> per sheet. Read the outline first: sheet names, sizes, merged regions. Then find the header rows. Then read data.
- Excel lock files (~$name.xlsx) are artefacts; the reader skips them.
- Some fixtures in this repo are Git LFS pointers rather than real workbooks (130-byte files). If a file will not open, check whether it is a pointer before concluding it is corrupt.
- Blank rows are meaningful — they separate stacked tables and, in tariff sheets, layers. The reader keeps and marks them.
- Merged headers are how a tariff marks a layer. A merged cell reads as empty in every cell but the first; the outline reports the merged ranges so you can see them.
Verifying a parsed tariff — the hard problem
A misread rate produces a plausible wrong premium. It does not error, the product configures cleanly, and the number looks like a number. So "I read it carefully" is not verification, and re-reading it yourself mostly reproduces your own mistake.
Four checks, cheap, and strongest last. Use them together — each catches a different failure.
1 · Cite every cell. Each rate in the model carries where it came from: file, page, table, row, column. This is not bookkeeping — it is what makes verification sampling instead of re-derivation. An uncited grid cannot be checked by anyone, including you.
2 · Extract twice, by different means, and require agreement. Table extraction and text-layout extraction fail differently. Where they disagree, look — that is the list of suspect cells, and it is usually short. This is the check that catches a silently dropped or merged column, which is the most dangerous parse failure because the grid still looks complete.
3 · Test the structure, not just the values. A rate grid is not arbitrary, and its shape is predictable even when its numbers are not:
- rates should move monotonically along an ordered axis (age, band, tier) — a break is either a real business decision or a transposition;
- a richer cover should not cost less than a thinner one at the same coordinates;
- every row group should have the same number of columns as its header.
Violations do not prove an error; they name the cells worth a human's attention. Transposed axes and shifted columns almost always show up here.
4 · Round-trip through the engine. Configure the grid, then quote sampled combinations and check that the premium equals the base × the rate as printed in the document. This is the strongest check available, because it proves the whole chain — parse, model, configure, compile, price — for that combination. Sample across the extremes and the boundaries, not the middle.
⚠ Reconcile the cell count before trusting any of it. tables × rows × columns must equal the number of rates in your model. A count that comes out short means a column or a row group was lost, and every check above will pass happily on the cells that survived.
The traps that make tariffs different from other documents
- A sub-table may select the whole grid, not adjust it. Deductible levels, coverage variants and regions are often published as separate complete tables, not as modifiers. Reading the first one and treating the rest as discounts gives correct-looking numbers for a third of cases and silently wrong ones for the rest. Count the tables before reading any of them.
- Row-group labels are merged. A category is written once and the rows beneath it are blank. Row-wise reading loses which group a rate belongs to and misattributes every rate but the first.
- Two stacked axes in one column block — category, then clause within it. That is a three-dimensional grid flattened onto paper.
- Conditions live in prose, in footnotes and inside cells: which discounts combine, which are mutually exclusive, what a tier applies to. Those are business rules, and they are as much a part of the tariff as the numbers.
- A PDF with no embedded font extracts as placeholder characters. If the text comes back as runs of meaningless glyphs, that is a font problem, not an empty document — re-extract by another route before concluding anything.
ipal-configurationIPAL is the Insurance Product Administration Layer: what the operator sees and can sell. It1,687 words
IPAL configuration
Confirmed live: LT_SOURCE_TRANSACTION_CODE exists (owner SRD_CUST) — it is simply not PR_ANNEX_TYPES' foreign key, which is why it was removed from the chain diagram rather than from the world. Column lists in this file are source-derived from IPALDbContext.cs unless a line says otherwise; the PR_BASIC_COVERS, PR_BASIC_COVER_DEF, PR_BASIC_LD and PR_ANNEX_TYPES lists matched that source exactly but have not each been re-checked against ALL_TAB_COLUMNS. Schema shapes are durable; row counts are not — query those.
IPAL is the Insurance Product Administration Layer: what the operator sees and can sell. It defines what can be insured, under what covers, with what rules. It does not decide the premium — that is analytics-ppa.md.
The vocabulary
| | Term | Is
| Product | the sellable thing — «a line of business», Health, Travel, Property
| Object | what is insured — CAR, PERSON, HOME, PROPERTY, GRPPA
| Cover | a coverage type — «COVER_A», «COVER_B», FIRE, THEFT
| Definition | a cover's parameters — LIMIT, DEDUCTIBLE, UNDERINS, IV
| Event / Risk | what is covered and against what
| LD | loading, discount, tax or fee affecting the premium
| Offer | a packaged presentation of covers — see offers-engine.md
The dependency chain
Nothing references a lookup code that does not exist yet. This ordering is why configuration-order.md exists.
PR_BASIC_PRODUCTS
├─ PR_BASIC_PRODUCT_NAMES (translations, via DESCR_LINK → SR_MESSAGES)
├─ PR_PRICING_FACTOR_VALUES [OBJECTS_CONFIG] (which objects) ─── LT_OBJECT_CODE
├─ PR_BASIC_COVERS (per object) ─── LT_COVER_CODE
│ ├─ PR_BASIC_COVER_NAMES
│ ├─ PR_BASIC_COVER_DEF (variables) ─── LT_VARIABLES
│ ├─ PR_BASIC_COVER_RISKS ─── LT_RISK_CODE
│ └─ PR_BASIC_COVER_EVENTS ─── LT_EVENT_CODE
├─ PR_BASIC_LD (loadings etc.) ─── LT_LD_CODES
├─ PR_BASIC_PRODUCT_DOCUMENTS ─── LT_DOC_CODES
└─ PR_ANNEX_TYPES (modifications) ─── keyed on PRODUCT_CODE, no lookup FK
Beyond the product tree, two things decide whether an operator can reach the product at all:
- SRD_SYS."Routes" — whether the product exists in the menu. Evaluated at every level of the tree, so a blocking ancestor hides the leaf. Quoted, case-sensitive identifiers. AllowedRoles here is where "only group 6 sees this product" lands — the class of customer requirement Vladimir raised (R5).
- SRD_SYS.LT_USER_ROLES — a role needs a row here and a mention in some Routes."AllowedRoles".
⚠ Three things about writing a Routes row:
- The identifiers are case-sensitive PascalCase — ParentId, IsMenuItem, ProductCode, ProcessBusinessKey. The camelCase→UPPER_SNAKE convention every other table uses does not apply, so a payload of parentId/productCode is rejected wholesale.
- Id is a RAW column — 32 hex characters, not a readable slug. ORA-01465: invalid hex number is what a mnemonic id gets you; generate one (e.g. an md5 hex digest) rather than typing it.
- Translate names an i18n key that must already exist in the frontend bundle. Adding a key by configuration alone does not create a translation: the menu then renders the raw key (ipal.quotes.dashboard.<x>) where its siblings show a label. A new product added as pure configuration should leave Translate NULL so the UI falls back to Title — which is what the sibling route Casco Demo does. Either half missing gives a dead role that looks configured.
Product
PR_BASIC_PRODUCTS — BASIC_PRODUCT_ID, BASIC_PRODUCT_CODE, LOB, GENERIC_NAME, VALID_FROM, VALID_TO. BASIC_PRODUCT_ID may equal the code; GENERIC_NAME is internal.
Display names live separately, in PR_BASIC_PRODUCT_NAMES, one row per locale (countryId / companyId), each pointing at a message. Convention: IPROD_PR_{basicProductId} or IPROD_PR_{productName}. Confirm what neighbouring products actually use before coining one — it is a convention, not a constraint, and it has drifted.
i18n works the same everywhere in IPAL: a DESCR_LINK resolves to SR_MESSAGES / SR_MESSAGES_LNG by msg_id, as a soft LEFT JOIN. Orphans exist and labels are shared across codes, so a missing translation shows as a blank label rather than an error. Messages are created with the message tool, not as ordinary records.
Objects
Objects are attached to a product through PR_PRICING_FACTOR_VALUES with pricingFactorCode = "OBJECTS_CONFIG"; the value is the object code and status carries the rule.
| | Status | Means
| M | mandatory main object — every policy has it; others may be added alongside
| O | optional, only alongside an M object
| U | optional, may stand alone or with other U objects
| MO | mutually exclusive — only one MO object per policy (e.g. PERSON or GRPPA)
attr2 = "1" limits the policy to one object of that type; attr3 carries the endpoint for adding the object. Object codes carry no special characters — underscores are not rendered in the UI.
Single- vs multi-object is the first structural decision about a product: «a line of business» is one CAR; Travel is PERSON + GRPPA with MO.
Covers
PR_BASIC_COVERS — one row per product × cover × object combination. Fields: BASIC_COVER_ID, BASIC_PRODUCT_ID, COVER_CODE, OBJECT_CODE, COVER_TYPE, COVER_ORDER, LIABILITY, VALID_FROM/VALID_TO.
- COVER_TYPE is COV (mandatory/main) or OPT (optional/additional). Nothing else.
- There is no name column. Labels live in PR_BASIC_COVER_NAMES, convention CNAME_{basicProductCode}_{coverCode}. Again: confirm against neighbours rather than assuming.
- COVER_ORDER is the UI order — and for products that use covers as level variants (some do is the known case) it is also the join key: join POL_COVERS on COVER_ORDER, never COVER_CODE.
Cover definitions — where limits actually live
PR_BASIC_COVER_DEF declares every variable a cover can carry.
| | Field |
| BASIC_COVER_ID | parent cover
| VAR_CODE | the variable, from LT_VARIABLES
| VAR_DIM | V = absolute value, P = percentage
| AMNT_FROM / AMNT_TO | the allowed range
| AMNT_LIST | ⚠ VARCHAR2(1), and null in practice. It cannot hold an enumeration. A list of allowed sums insured or limits is PR_PRICING_FACTOR_VALUES rows on the corresponding IV factor, not this column.
| CURRENCY, RISK_CODE, UI_ORDER |
Common variables: LIMIT, DEDUCTIBLE, DEDUCTIBLEV (percentage deductible), UNDERINS, IV.
⚠ Limits and deductibles are here, not in PR_PRICING_FACTORS. This is the single most common place to configure a product in the wrong table.
⚠ AMNT_FROM reaches the UI as factor.pricingFactorRangeFrom and is client-side only — it constrains the operator's input, not the backend.
Constraints worth checking before writing: AMNT_FROM <= AMNT_TO; VAR_DIM ∈ {V,P}; no duplicate (BASIC_COVER_ID, VAR_CODE, VAR_DIM).
An object's own attributes are pricing factors too, and a new product has none
The object card in the wizard — a CAR's type, make, model, registration number, engine, seats — is rendered from hidden pricing factors: UI_FLAG = 'N', PRICING_FACTOR_NAME = the camelCase attribute name, POLICY_TABLE = OBJ_CAR.<COLUMN>, VALUE_SOURCE = pc.SrObjects.ObjCar.<Prop>.
CAR_SUBTYPE carSubtype OBJ_CAR.CAR_SUBTYPE pc.SrObjects.ObjCar.CarSubtype
CAR_MAKE make OBJ_CAR.MAKE pc.SrObjects.ObjCar.Make (depends on CAR_SUBTYPE)
CAR_MODEL model OBJ_CAR.MODEL pc.SrObjects.ObjCar.Model (depends on CAR_MAKE)
⚠ A product configured without them opens its object step and then dead-ends: the card renders, the fields appear, and their dropdowns have zero options — because the values are global (CAR_SUBTYPE has 17 rows with a null PRODUCT_CODE) but the factor row is per product, and without it nothing tells the UI the attribute exists. Nothing errors; the wizard simply cannot be completed.
These are object plumbing, not tariff content. Copy the set from an existing product on the same object and re-parent it onto the new product's lead cover:
SELECT * FROM SRD_IPROD.PR_PRICING_FACTORS
WHERE PRODUCT_CODE = :donor AND OBJECT_CODE = :object
AND SALE_STAGE = 'POL' AND UI_FLAG = 'N' AND VALUE_SOURCE LIKE '%ObjCar%'
Risks and events
PR_BASIC_COVER_RISKS (→ LT_RISK_CODE) and PR_BASIC_COVER_EVENTS (→ LT_EVENT_CODE) associate a cover with the risks it insures and the events it pays on.
Neither table has a runtime consumer on either side — they read as config-CRUD only. That does not mean skip them; it means if a product behaves oddly, these are unlikely to be the cause. Verify before relying on this.
Loadings, discounts and taxes
PR_BASIC_LD, joined to LT_LD_CODES. The IPAL-side catalogue; its pricing counterpart is PPA_PRODUCT_LD (analytics-ppa.md).
| | Field |
| LD_CODE | from LT_LD_CODES
| LD_TYPE | LD or TAX — and nothing else
| LD_SIGN | 1 increases the premium, -1 decreases it
| BASIC_COVER_ID | null = product-level (all covers); set = that cover only
| SEP_ACCOUNT, NCB, FINST, REFUNDABLE |
⚠ There is no DIS type. A discount is LD_TYPE = 'LD' with LD_SIGN = -1. A fee rebate is TAX with -1. Getting this wrong is a check-constraint error (ORA-02290), which is at least loud.
Documents and annexes
PR_BASIC_PRODUCT_DOCUMENTS (→ LT_DOC_CODES) — full columns: BASIC_PRODUCT_DOCUMENT_ID, BASIC_PRODUCT_ID, DOCUMENT_CODE, OBJECT_CODE, RQ_STAGE, EVENT_CODE, EVENT_SUBCODE. Note it keys on BASIC_PRODUCT_ID while PR_ANNEX_TYPES two paragraphs down keys on PRODUCT_CODE — the two neighbouring product-level tables disagree, so check per table. It drives the Код dropdown under Документи → Създаване на нов; an empty configuration gives an empty dropdown, which is the usual "the document type isn't there" report.
⚠ Plural. It is sometimes written PR_BASIC_PRODUCT_DOCUMENT; the table is PR_BASIC_PRODUCT_DOCUMENTS. Likewise the annex factors table is PR_ANNEX_FACTORS, not PR_ANNEX_FACTOR.
PR_ANNEX_TYPES — which mid-term modifications the product allows, with PR_ANNEX_FACTORS for the factors an annex may change. Verified columns: PR_ANNEX_TYPE_ID, PRODUCT_CODE, ANNEX_TYPE, ANNEX_NAME, DESCR_LINK, DESCR_LINK_HELP, DISP_ORDER, REQ_PREM_RECALC, CONTRACT_TYPE.
⚠ Two corrections to the inherited documentation, both found by exporting a real product: this table keys on PRODUCT_CODE, not BASIC_PRODUCT_ID — it is one of the productCode-keyed entities like the pricing factors, not a basicProductId-keyed one. And the code column is ANNEX_TYPE; there is no SOURCE_TRANSACTION_CODE column on this table. LT_SOURCE_TRANSACTION_CODE does exist as a lookup table — it is simply not what PR_ANNEX_TYPES foreign-keys to, so it does not belong in the dependency chain above it.
Validation to run before declaring a product configured
- every cover has at least the events and risks its terms describe
- every cover definition references a VAR_CODE that exists in LT_VARIABLES
- AMNT_FROM <= AMNT_TO (there is no list to validate — see AMNT_LIST above)
- product documents are linked and their codes exist
- pricing factors referenced by rating tables exist on the PPA side too
- objects' M/O/U/MO combination is satisfiable — an O-only product cannot be sold
- offers reference covers that exist and limit restrictions are non-circular
making-a-product-issuableThe goal: a document arrives, and a person can open the UI, find the product, and issue a policy.2,986 words
Making a product issuable — the end-to-end delta
Established by two independent code sweeps (backend + frontend) plus live data, then cross-verified. The two halves confirm each other on the one point neither could settle alone — see The contract that makes this work. Caveats to carry: the frontend sweep read a branch behind master; re-verify the route/resolver files against whatever the target environment actually runs. Two end-to-end attempts, both stopped at the same boundary — worth knowing because the failure is not where you expect it: First: everything up to the wizard worked — structure, rating, a verified premium, the route, the tile — and the wizard never opened, because no step source was supplied. See Where the wizard steps come from. Second: with steps supplied, the wizard opened, every step rendered and the first one submitted — then task completion failed demanding a variable the product has no concept of, because the only deployed parameterised process was shaped for a different kind of risk. Function fixes the rendering, never the task chain. See Establish the deployed set BEFORE you build. The lesson both times: the configuration was correct and the product still could not be issued. Prove the process fits first.
The goal: a document arrives, and a person can open the UI, find the product, and issue a policy. What makes that tractable is reusing an already-deployed Camunda process, which keeps the work on the configuration side of the deploy boundary. That is about the process, not the product — the configuration itself comes from the documents. Reusing a process is not the same as cloning a product, and the second is not implied by the first.
The contract that makes this work
Routes carries three columns that flow all the way into the workflow engine:
SRD_SYS."Routes" UI (quotation.resolver) Core (UserTaskManager)
ProcessBusinessKey ──────────────► processType ────────────────────────► ProcessDefinitionKey
ProductCode ──────────────► StartProcess(…, productCode, …) ────► [OptionalInputVar]
Function ──────────────► StartProcess(…, stepsJson) ─────────► [OptionalInputVar]
That is why a new product can be one row: the row supplies the product identity and the wizard shape, and the process reads both as input.
⚠ ProcessBusinessKey is misnamed — it is the process definition key. Camunda's actual business key is <definitionKey>:<requestId>, built at start time.
Which process can you use — three tests, and only one of them is greppable
Choosing a process is the decision most likely to strand a finished product at the last step. It turns on three things, in this order:
1 · Is it DEPLOYED? The deciding property, and the one no source sweep can tell you. A process class can exist in the repository, compile, and read perfectly — and simply not be deployed to the Camunda the running service uses. Starting it returns 404.
Establish the deployed set from the RUNNING SYSTEM, never from the repo:
- point a route at the candidate key and start it — a 404 means not deployed, a 200 means it is;
- or read back the process definition ids the service returns on live tasks.
⚠ SRD_CAMUNDA in the IPAL Oracle is NOT the Camunda the service uses. On at least one environment it holds a single ancient process definition and zero instances, while the service returns live tasks with real definition ids — a leftover schema in a restored database. Concluding "nothing is deployed" from that table is wrong, and tempting, because the table is right there and reads cleanly. Ask the service, not the schema.
⚠ Check which source tree a process lives in. Observed: a repository carrying two plugin trees, one with ~180 BPMN files under active development and an abandoned one holding two files added by a single old commit and never integrated. A process from the abandoned tree greps as real and is not deployed. Compare the candidate's directory against where the bulk of the BPMN files live, and against which tree has recent commits.
2 · Is it parameterised on the product code? Necessary, not sufficient. A process is reusable across products iff its OnStartOfProcess declares productCode as an optional input and reads it from userInput. If it pins the code, reusing it issues policies under that product:
# candidates — parameterised
grep -rlE 'OptionalInputVar\("productCode"' --include="*UserTasks.cs" src/
# pinned — never reuse for a new product
grep -rlE 'HardcodeVar\("productCode"|AddOrUpdateExtVariable\("productCode", ExternalVariable\.String\("' \
--include="*UserTasks.cs" src/
The pinned set is much the larger one. Verify a candidate at runtime anyway: start it and read back the productCode it reports. A process that reports a code that is not yours pins it, whatever the source suggested.
3 · Does its task chain match your product's SHAPE? Routes.Function overrides which components the UI renders — it does not change the underlying task chain. A process whose BPMN expects vehicle data will open a wizard, render your steps, and then fail at task completion demanding a variable your product has no concept of. Rendering and completing are different things.
The honest consequence
On an environment where the only deployed, product-code-parameterised process is shaped for a different kind of risk, a product of your shape cannot be finished as configuration alone. That is a deploy boundary, not a configuration defect: either the matching process gets deployed, or a process is written for the product. Both are backend work.
Say so, with the evidence — which keys you tried and what each returned. Do not force a mismatched process to reach a green result; a product that opens and then fails at completion is worse than one honestly reported as needing a deploy. See boundaries.md.
When one does fit, prefer a process already used by a product of the same shape: it has its own steps and its chain already matches, so no Function is needed.
⚠ Do not infer reusability from route counts. Counting Routes rows per process suggests several shared processes; counting distinct product codes shows only one. Rows duplicate for language variants, and some carry a null product. This is the count-rows-not-entities error, and it is easy to repeat.
Where the wizard steps come from — three sources, very different costs
The step list is not a frontend-only concern. It can come from any of three places, and which one you use decides whether the change is configuration, a frontend deploy, or a backend deploy.
| | Source | Where it lives | To change it
| 1. Routes.Function (stepsJson) | a column on the route | configuration — no deploy. Read by the backend process as an optional input variable, and honoured on the way to the UI.
| 2. The backend process class | <ProcessKey>UserTasks.cs — ProcessSteps, a literal array in every non-generic class. Returned to the UI as processStepsDetails. | backend deploy. The trickiest to iterate: the BPMN and the class are loaded at Core startup, so you cannot try a change without rebuilding and restarting the service.
| 3. The frontend map | ProcessesSteps[businessKey] in the Angular config | frontend deploy — but you can serve the frontend locally against a real backend and test the change directly, so it is far easier to iterate than (2).
Precedence when the wizard resolves: Function → processStepsDetails → ProcessesSteps. If all three are empty the stepper maps over undefined and the wizard never opens.
Prefer (1). It is the only one that needs no deploy at all, and it is the mechanism the platform already uses in earnest for two products that share a process but need different wizards.
One class is usually built for exactly this — its ProcessSteps is null until stepsJson is supplied, so it takes both the product code and the whole step list as input. That makes it a genuine no-code product process, but it is unusable without a Function: Function null, no processStepsDetails (the class has nothing to return) and no frontend map entry means the wizard never opens. Identify it by that property — ProcessSteps returning null rather than a literal array — not by its name.
Establish the deployed set BEFORE you build
Membership of the parameterised set comes from reading source. It says nothing about deployment, and a key that is not deployed fails at the very first click, after everything else is already built.
The check, on the environment you are targeting: point one route at each candidate key in turn, start it, and record what comes back. Three outcomes, each decisive:
| | what you get back | what it means
| 404 | not deployed here — unusable, whatever the source says
| 200, reporting your product code | deployed and genuinely parameterised
| 200, reporting a different product code | deployed but pins its own code — it would issue policies under that product
Run it before configuring, not after. It is a handful of calls and it decides whether the product can be finished at all.
⚠ Expect the answer to be narrow. It is entirely possible for an environment to have exactly one deployed process that accepts an arbitrary product code — and for that one to be shaped for a different kind of risk than the product you are building. When that happens the product cannot be completed as configuration, no matter how correct the configuration is. That is the single most important thing to establish early, because everything else you do is wasted if it is true and you find out last.
Reading "Camunda Process not found"
The dialog names two different faults and the text does not distinguish them. What it interpolates tells you which:
| | The dialog names | Meaning
| a route path (e.g. agent-test-<code>) | the ProcessBusinessKey column really is missing
| a process definition key | the process started fine — the steps are missing
In the second case Camunda is healthy and the start call returned 200 with a real task. Do not go looking at the route column; go looking at the three step sources above.
Picking what to clone
Prefer the product with the smallest complete footprint — not the smallest. A product with few rows may be incomplete rather than simple, and cloning it copies the hole.
Check a candidate for: a DEPLOYED PPA version, at least one PR_OPERATIONS row, at least one PR_ANNEX_TYPES row, CFG_POLICY_NO_SEQ rows, CFG_PRINT_DOCS rows, and evidence that real policies have been issued on it. Prefer a person/property line over motor: vehicle products attract registry lookups, photo flows and hardcoded age/equipment logic.
The delta, in dependency order
D marks the deploy boundary.
0 · Choose the process. Confirm it is in the reusable set with the grep above.
1 · Product identity
- PR_BASIC_PRODUCTS — one row. ⚠ the key column is BASIC_PRODUCT_CODE, not PRODUCT_CODE.
- PR_BASIC_PRODUCT_NAMES, the PR_BASIC_COVER* catalogue, PR_BASIC_LD — cloned and re-parented.
- PR_ANNEX_TYPES — at least one, or nothing can be created.
- SR_MESSAGES / SR_MESSAGES_LNG for any new label keys.
2 · Factors — must precede rating; the engine validates against them. PR_PRICING_FACTORS → _VALUES → _DEPENDENT → PR_PRACING_FACTOR_DBFLD (sic — the typo is in the schema; it binds a factor to the column its value is stored in, and is easy to miss for that reason).
3 · Rating — see analytics-ppa.md and testing-a-product.md. PPA_PRODUCTS — ⚠ PRODUCT_ID is NOT NULL and has no sequence: nothing generates it, you must supply it. The current convention is PRODUCT_ID = PRODUCT_CODE (most live rows follow it; older ones carry small sequential ids from before the convention). PPA_PRODUCT_VERSIONS.VERSION_ID is sequence-backed — do not supply that one → PPA_FACTORS → open a version → covers/LDs → bind rate grids → genRuleTable → set DEPLOYED → ABC_CFG_PRICING_FACTORS so the factors actually reach the engine. Smoke-test with abacus.py quote before going further — an empty rates[] is a silent zero premium, and every later step will look fine while being wrong.
4 · Operations — PR_OPERATIONS, cloned. D if the product needs premium or underwriting logic no existing operation implements: operations are compiled classes selected by configuration rows.
5 · Numbering and documents
- CFG_POLICY_NO_SEQ per (product, year, office). ⚠ Missing does not throw — the policy saves with its internal id as its number, which is easy to miss until someone reads a policy document.
- CFG_PRINT_DOCS, cloned.
6 · Reachability — the frontend half, all rows, no deploy
- One Routes row: ProductCode, the reusable ProcessBusinessKey, Path, ParentId, ≥1 AllowedRoles, and IsDashboardItem. Leave ViewConfigId null; it is dead in the frontend. Function only if the product needs a different step list from the process default.
⚠ IsDashboardItem on a CHILD route is an open question — the source and the live data disagree, and this was previously written here as settled. sc-card-types.component.ts:35 builds a parent's card list as (data?.children || []).filter(i => i.isDashboardItem === 'Y') with no fallback, which says a child needs 'Y'. But on dev every motor casco product route (4703, 4704, 4716, 4728) sits under the "CASCO" parent carrying 'N'. Both cannot be right, and nobody has yet confirmed live whether those siblings actually render. Set 'Y' — it matches the code, and the parent route itself carries 'Y' in its parent's list, so 'Y' is what makes a route render as a card at any level. Then check the tile, and record what you see.
- OBJECTS_CONFIG — ⚠ There is no table of that name. It does not exist in any schema. It is a pricing factor code, and its rows are PR_PRICING_FACTOR_VALUES with PRICING_FACTOR_CODE = 'OBJECTS_CONFIG', PRICING_FACTOR_VALUE = the object code, and STATUS = M/O/U/MO. A product needs at least one at M or MO, naming an object code the UI can render.
- A translation key for the tile label.
7 · Onward transfer — CFG_MAPPING_*. Skip for a dev-only proof; required before a policy can leave Serdica.
Six things a new product needs that no database check will tell you
Driving a brand-new motor product (object CAR) through the UI surfaces these, in the order they bite. Every one of them fails silently or misleadingly, and none is visible to abacus.py quote — the quote returned a correct, reconciled premium the whole time.
First, a correction to this file's premise: VehicleCascoPolicyWithPhotos is deployed on dev and does accept an arbitrary productCode — the wizard opened for a product it had never seen, with no Function/stepsJson at all. GenericPolicy is not the only reusable process. Re-test the candidate set per environment rather than inheriting a previous run's conclusion.
- The object's own attribute factors. The object card renders, its dropdowns are empty, nothing errors. See ipal-configuration.md — the values are global, the factor rows are per product.
- DEPENDENT_ON_FACTOR needs the two-part form MAIN|model.path. A bare factor code is stored happily and filters nothing — the dependent dropdown shows every value and no request is even made. pricing-factors.md.
- An offer. "Get Quote" completes EnterPricingData / GetCarOffer, which answers 400 "Unable to complete camunda task! No offers could be found provided the insured object's data." when the product has no LB_* rows. The minimum is one LB_OFFERS row on segment 1 ('ALL') against a reused LB_OBJECT_NA row for the object, plus one LB_OFFER_COVERS row per cover. offers-engine.md.
- DURATION, DURATION_DIMENSION, PAYMENT_FREQUENCY. Without them the "Policy Information" step is three empty dropdowns with no way to submit. DURATION needs no PR_PRICING_FACTOR_VALUES rows of its own — its list comes from PR_PRICING_FACTOR_DEPENDENT.
- genRuleTable's company and country. Stamped on every row of the compiled artefact and matched against the runtime context. An invented company compiles cleanly and resolves for nobody. Survey the estate before compiling, and note that a quote will still price correctly against the wrong artefact.
- The tariff's effective date does NOT go on the covers. analytics-ppa.md.
What ends the no-deploy path
Any one of these: a new .bpmn (deployed from disk at Core startup); a new or edited *UserTasks.cs; a wizard step the registered component set cannot express; premium or underwriting logic no existing operation implements; a non-grid pricing rule needing a CLASS-type filter (a BLOB filter uploaded at runtime does not need a deploy); or an object shape the UI does not already handle.
The failures that are silent
Ordered by how long they take to notice.
| | Symptom | Cause
| No tile at all | IsDashboardItem ≠ 'Y', wrong parent, or role mismatch — but see the caveat above before concluding, and allow for the routes cache, which lags by minutes
| Wizard will not open, modal + redirect | no OBJECTS_CONFIG factor values
| Object step renders as an empty card, no error | OBJECTS_CONFIG values exist but none at M/MO — the nastiest one
| A step appears with a label and renders blank | Function names a component key that is not registered
| Empty required dropdown, Next stays disabled | dependent-factor list resolves empty
| Field never appears at any stage | sale stage is neither QT nor POL
| Premium is zero | rates[] empty — a 200 the platform does not treat as an error
| Policy number is the internal id | no CFG_POLICY_NO_SEQ row
| Wrong product opens | duplicate leaf Path — resolution searches the whole subtree, not just siblings
Known unknowns
Do not present these as settled: commissions and payment-plan configuration were not found to be product-keyed at all; quote numbering was not located; ViewConfig was not investigated on the backend. Each is a question to answer, not a gap to fill by assumption.
(The routes-cache question is now answered — it is cached, with a lag of minutes. See the warning in "Which of them are actually DEPLOYED".)
offers-engineOffers decide how a configured product is packaged and presented. IPAL says what exists and what1,651 words
Offers and the recommendation engine
Table and column names, confirmed two ways: (a) confirmed live via ALL_OBJECTS / ALL_TAB_COLUMNS — the four corrected table names (LB_SEGMENTS, CFG_COVER_LIMIT_RULES, CFG_COVER_RESTRICTIONS, CFG_BLOCKING_COVER_RESTRICTIONS) and the absence of a conflict-group column on LBRR_COVER_CONFLICTS. (b) source-derived only, from the scaffolded IPAL entities (Ablera.Serdica.DBModels.IPAL/DBModels/IPAL/IPALDbContext.cs) and not yet catalog-checked — the column lists for LB_OFFERS, LB_OFFER_COVERS, LB_COVERS_DEF, LB_OBJECT_NA and LBRR_COVER_DEPENDENCY. Treat (b) as reliable but unconfirmed. Schema owners, confirmed live: the LB_* / LBRR_* family is SRD_IPROD; the CFG_COVER_* family is SRD_INTEGR, not SRD_IPROD. LB_SEGMENTS exists in two schemas — SRD_IPROD and SDR_INSPROD (note that spelling; it is not a typo in this file). Qualify the schema when querying LB_SEGMENTS — which of the two is authoritative is not yet established.
Offers decide how a configured product is packaged and presented. IPAL says what exists and what ranges are allowed; offers say what goes in this package, at what value, for whom.
| | IPAL | Offers
| PR_BASIC_COVERS — which covers exist | LB_OFFER_COVERS — which covers are in this offer
| PR_BASIC_COVER_DEF — the allowed range | LB_COVERS_DEF — the specific value for this offer
That distinction — range vs value — is the whole model. Configuring a specific limit in PR_BASIC_COVER_DEF instead of LB_COVERS_DEF makes it the only allowed limit for every customer.
Objects
LB_OBJECT_NA — the object as the offer engine sees it, with a range:
| | Field |
| objectCode | CAR, PERSON, …
| mvRangeFrom / mvRangeTo | market value or age range
| objectGroup | further specification key
Range meanings are per object type and worth knowing: CAR with mvRangeTo = 0 means a products of that kind without an insured value; mvRangeTo ≠ 0 means with one. For PERSON the range is the allowed age band.
LB_OBJECT_NA_OBJECT_GROUP builds dynamic groups by concatenating factor values in valueOrder: e.g. group ABROAD_FAMILY = TERRITORIAL_VALIDITY → ABROAD (order 1) + AFFINITY_GAP → FAMILY (order 2). valueSrc says where each part comes from — POV (pol_object_values), a policy-context path such as pc.SrObjects.ObjCar.CarSubtype, or a computed value such as CAR_AGE.
How an offer is actually resolved — the chain that must be complete
An offer is not found by product code. The engine derives the insured object's group, matches that to an LB_OBJECT_NA row, and takes the offers hanging off it. Three links, and a break in any one produces the same message — "No offers could be found provided the insured object's data."
LB_OBJECT_NA_OBJECT_GROUP says WHERE to read the group from (valueSrc)
│
▼
derived group value ──match──► LB_OBJECT_NA (objectCode + objectGroup + mvRange)
│
▼
LB_OFFERS ──► LB_OFFER_COVERS
1 · LB_OBJECT_NA_OBJECT_GROUP is not optional. Without a row the engine has no group to derive and no match is possible. Creating LB_OFFERS and LB_OFFER_COVERS alone is never sufficient. For a vehicle product the row reads the group from the object itself (valueSrc = 'pc.SrObjects.ObjCar.CarSubtype', factorCode null); for others it names a factor with valueSrc = 'POV'. Copy the shape from a product of the same object type.
2 · ⚠ LB_OBJECT_NA.objectGroup is SHARED platform vocabulary, and this is the trap. The values are the platform's own set for that object type, not a product's. So a factor that feeds group derivation cannot have its values freely scoped to a product: reuse the factor code, scope its values to your document's vocabulary, and the derived group becomes a string no LB_OBJECT_NA row carries. The product configures cleanly, prices correctly, and finds no offers.
Before scoping values on any factor named by a valueSrc, check that each one exists as a group:
SELECT COUNT(*) FROM SRD_IPROD.LB_OBJECT_NA
WHERE OBJECT_CODE = :obj AND OBJECT_GROUP IN ( :your, :scoped, :values );
A zero is the answer before you build, not after. If the product genuinely needs vocabulary the platform lacks, LB_OBJECT_NA rows can be added — they are shared, so add, never modify or delete — and that is a deliberate extension of platform vocabulary, not a local rename.
3 · Register offers against BOTH mvRange variants. Each group normally has two rows: mvRangeTo = 0 (the variant without an insured value) and mvRangeTo != 0 (with one). Live products carry their offers on both. Registering only the ranged row leaves the other path finding nothing, and picking whichever row a naive lookup returns first is how offers end up on the 0..0 row where no policy with a sum insured can reach them.
Offers
LB_OFFERS — an offer is a product + object + segment combination, identified by offerCode. Columns: LB_OFFER_ID, BASIC_PRODUCT_ID, LB_OBJECT_NA_ID, LB_SEGMENT_ID, OFFER_CODE. An offerCode starting with PACKAGE is a bundle with fixed insured values.
LB_OFFER_COVERS (LB_OFFER_COVER_ID, LB_OFFER_ID, BASIC_COVER_ID, REC_EXTENT) assigns covers — by BASIC_COVER_ID, the PR_BASIC_COVERS key, not by cover code — and REC_EXTENT decides how they appear:
| | recExtent |
| M | mandatory — always included
| R | recommended — pre-selected, removable
| O | optional — the customer chooses
LB_COVERS_DEF sets the concrete variable values for a cover within this offer (an amount, not a range), keyed on LB_OFFER_COVER_ID + VAR_CODE. Full columns: LB_COVERS_DEF_ID, LB_OFFER_COVER_ID, VAR_CODE, VAR_DIM, AMNT, CURRENCY, RISK_CODE. The value is AMNT, and it carries its own VAR_DIM (V/P) and CURRENCY — an offer value can be a percentage, not only an absolute amount.
Pricing overrides for an offer live in LB_OFFER_PRICING_RULE; add-ons in LB_OFFERS_ADDITION; customer segments in LB_SEGMENTS (plural — LB_SEGMENT does not exist).
Business rules between covers
Dependencies — LBRR_COVER_DEPENDENCY
A cover that cannot be taken alone. Columns: LBRR_COVER_DEPENDENCY_ID, BASIC_PRODUCT_CODE, COVER_CODE, DEPEND_ON, OBJECT_CODE. Classic case: THEFT requires FIRE.
⚠ The product column here is BASIC_PRODUCT_CODE, but on LBRR_LIMIT_RESTRICTIONS the same concept is PRODUCT_CODE. The LBRR_* family is not internally consistent — check the column per table. Dependencies are also object-scoped (OBJECT_CODE): on a multi-object product the same cover pair can depend differently per object.
Conflicts — LBRR_COVER_CONFLICTS
Columns (verified against the backend's scaffolded IPAL entity, IPALDbContext.cs): LBRR_COVER_CONFLICT_ID, BASIC_PRODUCT_CODE, COVER_CODE, OBJECT_CODE. That is the whole table.
⚠ There is no conflictGroup column. A conflict is a flat, object-scoped row per cover; the grouping has to be inferred from the set of rows sharing a product+object, not read off a group key. Anything that writes a conflictGroup fails with ORA-00904.
Confirmed against live data: the rows sharing a product+object are the mutually-exclusive set. The recognisable shape is a family of alternative variants of the same cover — different clause sets or tiers of one protection — of which exactly one applies.
So one product+object supports exactly one conflict set. A product needing two independent either/or choices on the same object cannot express both here — that is a real modelling limit worth raising rather than working around.
Limit restrictions — LBRR_LIMIT_RESTRICTIONS
Relationships between insured values across covers. Two rows describe one restriction:
Columns: LBRR_LIMIT_RESTRICTION_ID, PRODUCT_CODE, OBJECT_CODE, COVER_CODE, SUM_GROUP, PERCENTAGE, DEPENDENT_ON, TYPE, DESCR_LINK.
- the main cover carries sumGroup (a unique number) and percentage
- the dependent cover carries dependentOn = <that sumGroup>, a type, and a descrLink for the warning the operator sees
| | type | Means
| EXC | must not exceed X% of the main cover's IV
| EQ | must equal the main IV
| FALL | must not fall below X%
Example — THEFT_DAMAGE ≤ 10% of FIRE: on FIRE, sumGroup = 17, percentage = 10; on THEFT_DAMAGE, dependentOn = 17, type = EXC, plus the warning message link.
⚠ Check for circularity before writing a set of restrictions. A cycle is accepted by the tables and fails at quote time, which is a much worse place to find it.
Also in the neighbourhood
CFG_COVER_LIMIT_RULES, CFG_COVER_RESTRICTIONS, CFG_BLOCKING_COVER_RESTRICTIONS — configuration-side limit and restriction rules. All three are plural; the singular forms do not exist. (CFG_COVER_LIMIT_RULES_ADD is a fourth, separate table.) Related but not the same as the LBRR_* family; check which one the product actually uses rather than assuming.
How offers reach the tariff — exactly one link
The rating engine knows nothing about offers. Nothing in the Analytics tree reads any offer table. The whole bridge is the template code:
LB_OFFERS ──► LB_OFFER_PRICING_RULE ─────────────► PPA_PRODUCT_VERSIONS
.LB_OFFER_ID = .LB_OFFER_ID .TEMPLATE_CODE = .TEMPLATE_CODE
(join key) (the payload) (a complete parallel tariff)
⚠ The join is on LB_OFFER_ID, not on an offer code. LB_OFFER_PRICING_RULE has no OFFER_CODE column — reaching for one gives ORA-00904. LB_OFFERS.OFFER_CODE exists, which makes the wrong guess an easy one.
With no pricing rule, the offer code on the policy object is used directly as the template code. So an offer influences pricing only by naming a rating version — that, and nothing else.
This is how one product carries several tariffs: a template scopes an entire version, with its own covers, loadings, grids, rules, filter and effective date. Channel, term and currency variants are each a version under their own template. The engine attaches no meaning to a template's name — whatever a code implies is imposed by whoever selects it, so never infer semantics from the string.
⚠ Two consequences that surprise people:
- A quote prices every cover of the resolved version, not the ones selected. Cover selection is applied by the caller, so a cover configured in rating but absent from the offer still comes back in the result. Do not read that as a configuration error.
- An unknown template code does not raise. It falls back — to the environment default, then to an arbitrary version — logging only a warning. A typo therefore prices silently against a different tariff. Verify the template you configured is the one actually resolved.
LBRR_* — conflicts, dependencies, limit restrictions — are eligibility and validation on the IPAL side. The rating engine never reads them.
pricing-factorsPricing factors are the inputs — what the operator is asked, and what the rating engine keys on.2,953 words
Pricing factors
The PR_PRICING_FACTOR_DEPENDENT column list below is source-derived from IPALDbContext.cs and not yet catalog-checked. The UI behaviour section is first-party source, read directly from serdica-ui — it does not depend on the database and needs no catalog confirmation.
Pricing factors are the inputs — what the operator is asked, and what the rating engine keys on. Three tables plus one for annexes:
PR_PRICING_FACTORS → PR_PRICING_FACTOR_VALUES → PR_PRICING_FACTOR_DEPENDENT, and PR_PRICING_FACTOR_ANNEX.
Levels
| | Level | COVER_CODE | OBJECT_CODE | Applies to
| policy | empty | empty | the whole policy
| object | filled | filled | one object type
PR_PRICING_FACTORS — the fields that matter
Columns: PRICING_FACTOR_ID, PRODUCT_CODE, COVER_CODE, OBJECT_CODE, PRICING_FACTOR_CODE, POLICY_TABLE, PRICING_TYPE, PRICING_FACTOR_NAME, DESCR_LINK, DATATYPE, PRICING_FACTOR_RANGE_FROM/_TO, UI_FLAG, STATUS, UI_ORDER, UI_GROUP, UI_GROUP_DESCR_LINK, UI_HELP_DESCR_LINK, QT_REQUIRED, UI_GROUP_DESCR_LINK_EXT, DEPENDENT_ON_FACTOR, VALUE_SOURCE, SALE_STAGE, HAS_DEFAULT_VALUE, DEFAULT_VALUE.
⚠ The range columns are PRICING_FACTOR_RANGE_FROM / _TO. RANGE_FROM / RANGE_TO throws ORA-00904 — a mistake worth not repeating.
| | Field | Notes
| PRICING_TYPE | what the factor feeds, not its type — BP (base premium) or LD (a loading/discount). Three characters; putting a datatype here fails with ORA-12899.
| DATATYPE | the input type — LIST, NUMBER, BOOLEAN, TEXT, TEXTAREA, the IV family
| UI_REQUIRED | NOT NULL, and separate from QT_REQUIRED — easy to miss until an insert rejects
| POLICY_TABLE | where the answer is stored — POV is much the most common; OBJ_CAR.R_DATE and similar dotted forms name a specific column
| VALUE_SOURCE | POV = supplied at quote time · pc.SrObjects.ObjCar.* = read from the saved object · computed = a backend function
| QT_REQUIRED | mandatory at data entry. Flat Y/N — it cannot express conditional requiredness. First stop for any "field X should/shouldn't be mandatory" question; not CFG_FLD_VALIDATION.
| UI_FLAG | Y visible, N hidden
| UI_GROUP / UI_ORDER | screen section and position
| SALE_STAGE | POL unless the factor is a pure UI prompt that never affects rating. Only POL factors sync to PPA — a factor used in a rating table must be POL.
| DEPENDENT_ON_FACTOR | format `MAINFACTOR
Datatypes
Simple: NUMBER, TEXT, TEXTAREA, BOOLEAN, DATE, LIST.
Insured-value family — these differ in scope and when the operator sees them:
| | Datatype | Scope | Input | Where | Visible
| IV | all covers | free | its own section | before the offer
| IVLIST | all covers | dropdown | its own section | before the offer
| IV_COV | one cover | free | under the cover | after the first offer
| IV_COVLIST | one cover | dropdown | under the cover | after the first offer
Only IV-type factors carry a currency (hasCurrency=Y), and a cover may have one IV factor with currency.
PR_PRICING_FACTOR_VALUES
Columns: PRICING_FACTOR_VALUE_ID, PRICING_FACTOR_CODE, PRICING_FACTOR_VALUE, PRICING_FACTOR_VALUE_NAME, DESCR_LINK, STATUS, PAS_VALUE, GFUND_VALUE, ANLT_VALUE, ATTR1..3, UI_ORDER, DEFAULT_VALUE, PRODUCT_CODE.
- PRODUCT_CODE null = the value applies to every product.
- PAS_VALUE / ANLT_VALUE / GFUND_VALUE are the equivalents in the downstream systems — set them when the value has to be recognised there.
- ⚠ ATTR3 means different things on different factors. On most it is a per-row role gate. On OBJECTS_CONFIG it is not a role at all — it holds the endpoint path that registers that object on a quotation, in <Section>/<Command>/<action> form, and the value differs per object type. Configure it by copying what live products carry for the same object, never by analogy with another factor:
SELECT PRICING_FACTOR_VALUE, ATTR3, COUNT(*) FROM SRD_IPROD.PR_PRICING_FACTOR_VALUES
WHERE PRICING_FACTOR_CODE = 'OBJECTS_CONFIG' AND ATTR3 IS NOT NULL
GROUP BY PRICING_FACTOR_VALUE, ATTR3 ORDER BY 3 DESC
The general lesson is worth more than the specific column: an ATTR* slot is untyped, so its meaning is set by whoever populates it. Before filling one, look at what products carrying the same factor put there.
- On most factors ATTR3 is a per-row role gate. Canonical case: PAYMENT_FREQUENCY values 6/12 gated to UR_12_INSTALLMENTS. This is a second place a "who can see this" requirement can land, alongside Routes."AllowedRoles".
- OBJECTS_CONFIG is itself a factor code, not a table — its values are the object codes a product offers. STATUS carries M/O/U/MO (the product needs at least one M/MO or the object step renders empty) and ATTR2 = 1 limits it to a single object.
- The primary key is sequence-defaulted — never pass it.
Dependencies
PR_PRICING_FACTOR_DEPENDENT restricts which values of a dependent factor are available for each value of its driver: PRICING_FACTOR_CODE_MAIN + PRICING_FACTOR_VALUE_MAIN → PRICING_FACTOR_CODE_DEP + PRICING_FACTOR_VALUE_DEP.
Full verified column list: PRICING_FACTOR_DEPENDENT_ID, PRODUCT_CODE, PRICING_FACTOR_CODE_MAIN, PRICING_FACTOR_VALUE_MAIN, PRICING_FACTOR_CODE_DEP, PRICING_FACTOR_VALUE_DEP, PRICING_FACTOR_VALUE_NAME, DESCR_LINK, STATUS, DEFAULT_VALUE, UI_ORDER, ATTR1..3, PAS_VALUE, ANLT_VALUE, GFUND_VALUE.
- PRODUCT_CODE is on this table, and it is nullable — null means global. Global rows are the larger share of this table, so a new product inherits most dependency rules for free and only needs its own where it genuinely differs. Check which kind you are looking at before copying: duplicating a global row per-product is how shared reference data gets forked.
SELECT NVL(PRODUCT_CODE,'(global)'), COUNT(*) FROM SRD_IPROD.PR_PRICING_FACTOR_DEPENDENT
GROUP BY PRODUCT_CODE ORDER BY 2 DESC
- A scoped dependency copied without its product code silently applies nowhere; a global one copied with a product code narrows a rule that used to be universal. Both are quiet.
- It carries both PRICING_FACTOR_VALUE_NAME and DESCR_LINK, so a dependent value's label can be a literal or a translated message. Do not assume the literal is the only option.
- ⚠ PRICING_FACTOR_VALUE_NAME is not optional in practice, and omitting it produces a third kind of empty dropdown. Every working dependency set on dev populates it on every row — CAR_MAKE→CAR_MODEL 5168/5168, CAR_SUBTYPE→RASS_CARD 38/38 on both 4703 and 4704, DEDUCTIBLE→FRANCHISE_TYPE 7/7, DURATION_DIMENSION→DURATION 379/379 — and the only live rows that leave it null drive a hidden factor nothing renders. With it null the list is returned and the default is still applied underneath, so the field has a value while the option panel shows nothing. It is easily misread as a frontend defect, or as the missing-dependency-row case above; the query that separates them is SELECT COUNT(*), COUNT(PRICING_FACTOR_VALUE_NAME) FROM SRD_IPROD.PR_PRICING_FACTOR_DEPENDENT WHERE PRODUCT_CODE = :code.
- STATUS is null on every live row. Inventing a value for it is a second way to make a row that reads as configured and behaves as absent.
- STATUS and the three downstream mirrors (PAS_VALUE, ANLT_VALUE, GFUND_VALUE) exist here just as they do on PR_PRICING_FACTOR_VALUES.
Example — DURATION depends on DURATION_DIMENSION: D → 1, 5, 10 · M → 1, 3 · Y → 1.
A dependent factor still has its own PR_PRICING_FACTOR_VALUES rows, typically many more than any one driver value admits. The dependency table does not replace that list, it selects from it per driver value. So a dependent dropdown has three ways to come up empty, identical on screen and fixed in three different places: no values at all; values that no dependency row admits for the chosen driver; or admitted rows carrying no PRICING_FACTOR_VALUE_NAME, so the options exist and render blank.
How the UI actually behaves — from the source, not inferred:
- libs/serdica-utils/src/lib/serdica-utils/formly/field-generator.ts:523 — on a driver change the dependent list reloads via getPrfactorListDep(factor.pricingFactorCode, mainFactor, val, productCode). ✅
- field-generator.ts:547 — list.find(o => o.defaultValue === 'Y') is auto-selected into the control. ✅
- field-generator.ts:538-544 — when the list comes back empty, the field's value is cleared (field.formControl.setValue(null)) and the handler returns. The field is still rendered, still enabled, and still required if QT_REQUIRED='Y'.
⚠ An empty dependent list does not mean "the factor does not apply." Nothing in the field generator hides, disables or skips the field — there is no hide logic in that file at all, and the second code path (formly/helpers.ts:121-140, dependentOnFactorFn) only assigns field.props.options and likewise never hides. A missing dependency row on a required factor therefore produces an empty, mandatory, unfillable dropdown that blocks the quote — a hard stop, not a benign "not applicable". Model a genuinely-inapplicable factor with visibility (UI_FLAG) or requiredness (QT_REQUIRED), not by leaving its dependency rows out.
Object attribute visibility
OBJ_ATTR_DEPENDENCY hides or shows an object's attributes based on another attribute. Columns — all six are NOT NULL: OBJ_ARRT_DEPENDENCY_ID (sic — the typo is in the schema, and in its sequence too), OBJECT_CODE, OBJECT_SUBTYPE_NAME, OBJECT_SUBTYPE_VALUE, OBJ_ATTR_NAME, OBJ_ATTR_APPLICABLE.
⚠ The controlling attribute is OBJECT_SUBTYPE_NAME/_VALUE, not attrCode/attrValue. That naming is narrower than it looks: the driver is the object's subtype, so this table expresses "for this subtype of object, is that attribute applicable" — not arbitrary attribute-to-attribute logic.
Example — CAR with carSubtype = CAR: show engineType, engineCc, enginePower; hide boatLength.
To make an object attribute mandatory: QT_REQUIRED = Y on the factor, with the attribute name as PRICING_FACTOR_NAME.
A range and its value list can contradict each other, and the UI will deadlock
PRICING_FACTOR_RANGE_FROM/_TO on the factor and the rows in PR_PRICING_FACTOR_VALUES are two independent sources. Nothing keeps them consistent, and when they disagree on an IVLIST the operator gets a dropdown whose only option fails the validator — an unfillable required field, and a product that cannot be sold through the UI at all.
An IVLIST factor with RANGE_FROM = RANGE_TO = 60000 and DEFAULT_VALUE = '60000', whose single allowed value is 30677.51. The operator sees "Value cannot be less than 60000" under a field they cannot change.
The cause is a half-finished currency changeover. 60000 ÷ 1.95583 = 30677.51 — the fixed BGN→EUR rate. The values were converted; the range and default on the factor were left in BGN. The same signature appears on another product: default 25000, allowed value 12782.30, which is the same division.
Check this whenever you clone or configure an IVLIST. A clone reproduces the contradiction faithfully, so a product inheriting it looks correctly configured, prices correctly through the rating engine, and is still unsellable. The query:
SELECT f.product_code, f.pricing_factor_code, f.pricing_factor_range_from, f.default_value,
v.pricing_factor_value
FROM SRD_IPROD.PR_PRICING_FACTORS f
JOIN SRD_IPROD.PR_PRICING_FACTOR_VALUES v
ON v.pricing_factor_code = f.pricing_factor_code AND v.product_code = f.product_code
WHERE f.datatype = 'IVLIST' AND f.pricing_factor_range_from IS NOT NULL
AND TO_CHAR(v.pricing_factor_value) <> TO_CHAR(f.pricing_factor_range_from)
If a division by 1.95583 reconciles the two numbers, it is the changeover and the range is the stale side — the values are already in EUR.
⚠ This is also why the rating engine agreeing is not sufficient evidence. getRates never sees the range; it prices whatever value you hand it. Only the UI enforces the range, so a product can price perfectly and still be unusable.
Building a factor tree from a document
This is the normal case and the largest piece of work in most products: the document names the questions, and you build them. Copying a similar factor is a shortcut you take within this — never a substitute for it.
Build in this order. Each level references the one above by code, so out-of-order writes attach to nothing:
- The factor — PR_PRICING_FACTORS. Decide five things before writing: DATATYPE (the IV family is a real decision, not a detail — see above), level (policy vs object, via COVER_CODE/OBJECT_CODE), POLICY_TABLE, SALE_STAGE (POL if it feeds rating at all), and QT_REQUIRED.
- Its label — SR_MESSAGES + SR_MESSAGES_LNG, and point DESCR_LINK at it. A factor with no label renders as its code. See translations.md.
- Its values — PR_PRICING_FACTOR_VALUES, for every LIST/IVLIST/*LIST factor. Set UI_ORDER deliberately; set DEFAULT_VALUE='Y' on exactly one, or the field opens empty. Each value that a person reads needs its own DESCR_LINK.
- Its dependencies — PR_PRICING_FACTOR_DEPENDENT, only if the document says one answer restricts another. One row per admissible pair: (driver, driver value) → (dependent, dependent value). Miss a pair and that combination dead-ends in an empty required dropdown.
- The storage binding — PR_PRACING_FACTOR_DBFLD (sic), binding the factor to the column its answer is written to. Easy to forget precisely because of the typo.
- Reaching the engine — a factor is not visible to rating until it is declared in PPA_FACTORS and ABC_CFG_PRICING_FACTORS. See analytics-ppa.md.
Then quote. A factor the engine does not know about produces 400 Missing features {…} or a bare 500 naming it — both are cheap, immediate feedback (testing-a-product.md).
Reading the document into this shape
The document rarely uses these words. What to look for:
| | The document says | What it is
| a question with a fixed set of answers | LIST + values
| "only if …", "when X is Y, then Z can be …" | a dependency, one row per admissible pair
| a sum insured, a limit, a coverage amount | the IV family — pick the scope carefully
| "mandatory", "required" | QT_REQUIRED='Y' — flat, and it cannot be conditional
| a table whose rows are keyed by an answer | that answer is a rating factor: SALE_STAGE='POL'
⚠ QT_REQUIRED cannot express "required only when…". A document that says "mandatory for commercial vehicles" has no direct configuration. Model it with the dependency/visibility mechanisms or raise it — do not silently make it always-required, and do not silently make it optional.
Copying, when it is genuinely faster
Within a product being configured from a document, reuse is fine and sensible: an existing factor of the same shape, an existing value list, an existing dependency pattern for the same pair. Copy in the same order as above — definitions, then values, then dependencies.
Extending an existing value list: read the current maximum UI_ORDER first, then append, or the new values sort into the middle of the dropdown.
Writing them
Factors, values and dependencies are written in SQL like everything else — pcwrite.apply("PrPricingFactorsCreate", …), …ValuesCreate, …DependentCreate. It maps the gateway-shaped payload onto the real columns and is idempotent, so a re-run changes nothing.
Three rules that decide whether a factor's value reaches the engine
A row in PR_PRICING_FACTORS makes a factor exist. Three separate things decide whether its value arrives at the rating engine, and each fails differently.
⚠ Two different things are called a "stage" — do not conflate them. PR_PRICING_FACTORS.SALE_STAGE takes POL or QT and decides which wizard step renders a control. The rating request carries its own saleStage, drawn from the policy/annex row, and its values are not the same vocabulary — a live casco quote sends APL (application) with isQuote: "N". So a rule about SALE_STAGE says nothing about what the rating call reports, and vice versa. When reasoning about "which stage", say which field you mean.
1 · A rating axis must be required at quote stage, or it is dropped before any lookup
The premium extractor builds its candidate factor sets — both the object-level and the policy-level one — filtered on QT_REQUIRED = 'Y'. A factor with 'N' is excluded before any source lookup runs. Two consequences, and the second is what makes it hard to diagnose:
- it never reaches the engine, so the compiled grid cannot find it; and
- it is never added to the validated set either, so the parameter-completeness check never fires.
So the failure surfaces as a grid error, not a missing-parameter error. Tell the two layers apart before hunting:
| | Error | Layer | Means
| The following pricing factors are missing: <x> | policy/completeness, before rating | the check ran and the factor was absent
| Missing features {'X','Y'} (often wrapped as a gateway error) | the rating engine itself | the grid needs an axis nobody supplied — frequently because QT_REQUIRED excluded it
| Default value is required when rule is incomplete! | the rating engine | a value was supplied and matched no cell in a grid that has no catch-all
Any factor used as a rating axis needs QT_REQUIRED = 'Y', whatever a donor does.
2 · The source key decides where the value comes from
POLICY_TABLE is not documentation, it is a dispatch key:
- the submitted-values key (POV) routes extraction to what the wizard collected — the operator's selection;
- an object-column source (OBJ_CAR.<column>) reads that column directly and ignores the submitted payload entirely.
So a factor the operator chooses on screen must use the submitted-values key. Pointing it at an object column silently substitutes whatever the registry or a VIN lookup put in that column.
3 · An axis built on a vocabulary the engine never receives misses every cell
This is the quiet one. A grid keyed on values that never arrive does not error at compile — it compiles perfectly and then misses at lookup, or falls to a catch-all and prices as if the factor did not apply.
Before compiling, compare the two sides for every categorical axis:
-- what the grid keys on: read the level sets out of the grid file
-- what the engine receives: the actual contents of the source
SELECT <column>, COUNT(*) FROM <source table> GROUP BY <column> ORDER BY 2 DESC;
Two failure shapes, and only one of them is loud:
- No overlap and no (Other) → every lookup misses → Default value is required when rule is incomplete!. Loud, and easy to trace once you know the message.
- Partial overlap with an (Other) → the unmatched values fall to the catch-all and are priced as if the loading did not apply. Silent, and it under-charges. Free-text-ish columns are where this lives: the same marque can appear as MERCEDES BENZ, MERCEDES-BENZ and MERCEDES, and a level set naming only one of them quietly exempts the others.
Case is not the hazard — levels and values are upper-cased on both sides. Spelling, punctuation and abbreviation are.
product-modelThe model is the semantic representation of a product, independent of any schema. It exists so2,305 words
product-model.json — the spec
The model is the semantic representation of a product, independent of any schema. It exists so that the hard part — reading documents and getting the structure right — can be done, reviewed, and corrected without touching a database.
"Може просто да си бърка по джейсъните, докато ги нагласи — няма нужда да го връзваш към база, към нищо, докато се нагласят."
It is also the round-trip currency: an existing product exports into this shape, and a diff of two models is how the agent's work is graded.
Principles
- Business vocabulary, not table names. A cover has a key, names, a type, and definitions — not a BASIC_COVER_ID. The mapping to tables happens at apply time.
- Every element cites its source. Which document, which sheet, which clause. A model element with no provenance is a guess.
- What is unknown is recorded, not filled in.
- The model is complete before anything is written. Incomplete is fine; silently incomplete is not.
Shape
{
"schema_version": "1",
"meta": {
"code": "«PRODUCT_E»",
"names": { "bg": "…", "en": "…" },
"lob": "«a line of business»",
"class": "motor", // knowledge/discovering-a-product.md
"valid_from": "2026-09-01",
"business_line": "bulstrad_insurance",
"derived_from_product": «SOURCE_PRODUCT», // when cloning; null when built from nothing
"sources": [
{ "file": "sources/Product_Template_X.xlsx", "role": "structure" },
{ "file": "sources/<tariff-file>", "role": "tariff" },
{ "file": "sources/requirements.docx", "role": "requirements" },
{ "file": "sources/general_terms.pdf", "role": "terms" }
]
},
"objects": [
{ "code": "CAR", "status": "M", "one_only": true, "source": "template!Objects A4" }
],
"covers": [
{
"key": "«COVER_B»",
"names": { "bg": "…", "en": "…" },
"type": "COV|OPT",
"object": "CAR",
"order": 20,
"risks": ["…"],
"events": ["…"],
"definitions": [
{ "var": "LIMIT", "dim": "V", "from": 0, "to": 100000, "currency": "EUR",
"list": null, "source": "terms §4.2" }
],
"source": "template!Covers row 7"
}
],
"ld": [
{ "code": "LD_YOUNG_DRIVER", "type": "LD|TAX|SR", "sign": 1, "scope": "product|cover:<key>",
"rate_dim": "P", "rate": 10,
"rate_base": "BP|IV|FINAL_PREMIUM", // FINAL_PREMIUM = applied after all other LDs
"combination": "SUM|MULT|SEQUENTIAL_LAST", // per-LD override of the version's ld_rule
"order": 10,
"rule": "prose, as written in the source",
"source": "tariff!Loadings row 3" }
],
// Fixed-amount add-ons that are neither a cover rate nor a loading — assistance cards,
// mechanical-breakdown fees. They have an amount and a currency, not a rate.
"ancillary_premiums": [
{ "code": "RA_CARD_A", "name_bg": "…", "amount": 30, "currency": "BGN", "source": "…" }
],
// Tariff-level limits and calculation rules that are not themselves an LD.
"tariff_constraints": [
{ "code": "MIN_PREMIUM", "kind": "premium_min|sufficient_premium|discount_prc_max|ld_conflict|calculation_order",
"amount": 240, "currency": "BGN", "rule": "…", "affects": [], "source": "…" }
],
"factors": [
{ "code": "«FACTOR_A»", "datatype": "LIST", "level": "policy|object",
"required": true, "default": null, "depends_on": null,
"ui": { "group": "Vehicle", "order": 10, "visible": true },
"values": [ { "value": "…", "name": "…", "default": false } ],
"source": "template!Factors" }
],
"tariff": {
"template": "DEFAULT_TEMPLATE",
"versions": [ { "effective_from": "2026-09-01", "ld_rule": "SUM|MULT" } ],
// REQUIRED when the source repeats its rate tables under more than one qualifier.
// Name the axes explicitly and say what carries them in the document — this is the
// field that stops layer miscounts. See "Layers" below.
"layer_model": { "axes": ["deductible (3)", "vehicle_group (3)"],
"grid_axes": ["CLAUSE (7 rows)", "VEHICLE_AGE (6 cols)"],
"why": "how the document carries each axis, and what a linear read would miss" },
"cover_rates": [
{ "cover": "«COVER_B»", "shape": "flat|conditional|grid",
"base": "«FACTOR_IV»", "rate_dim": "P",
"grid": { "row_factor": "«FACTOR_IV»", "column_factor": "«FACTOR_A»",
"row_labels": [], "column_labels": [], "rates": [[]] },
"conditions": [],
"min": null, "cap": null,
"source": "tariff!the tariff B2:F40" }
]
},
"offers": [
{ "code": "PACKAGE_STANDARD", "segment": "…", "object": "CAR",
"covers": [ { "cover": "«COVER_B»", "extent": "M|R|O",
"values": [ { "var": "LIMIT", "amount": 50000 } ] } ],
"restrictions": [ { "cover": "THEFT_DAMAGE", "type": "EXC",
"of": "FIRE", "percentage": 10 } ],
"dependencies": [ { "cover": "THEFT", "requires": "FIRE" } ],
"conflicts": [ { "group": "G1", "covers": ["A","B"] } ],
"source": "requirements §5" }
],
"requirements": [
{ "source": "requirements §3.2",
"rule": "only role group 6 may see this product",
"maps_to": "Routes.AllowedRoles",
"confidence": "high|medium|low" }
],
"reconciliation": {
"covers": { "claimed_in_source": 7, "in_model": 7, "evidence": "template!Covers rows 4–10" },
"ld": { "claimed_in_source": 5, "in_model": 5, "evidence": "tariff!Loadings" },
"factors": { "claimed_in_source": 12, "in_model": 12, "evidence": "…" },
"tariff_layers": { "claimed_in_source": 9, "in_model": 9, "evidence": "…" },
"rate_cells": { "claimed_in_source": 378, "in_model": 378, "evidence": "…" }
},
"unresolved": [
{ "what": "deductible range for cover GLASS",
"why": "cannot configure PR_BASIC_COVER_DEF without it",
"expected_in": "general terms §4",
"blocking": true }
]
}
Layers — the miscount this spec is designed against
Veselin's warning is that when a tariff does not describe its layers, the agent counts them wrong. A real tariff worked during development is the reference case, and it defeats three different naive readings:
| | Naive reading | Counts | Why it is wrong
| "the clauses are the layers" | 7 | clauses are the grid's row axis
| "the blocks on page 1 are the layers" | 3 | that is only the vehicle-group axis of one deductible variant
| "one grid per page" | 6 | pages 2/4/6 are loadings and conditions, not rates
The true count is 9 — 3 deductible variants × 3 vehicle groups — and neither axis is announced. The deductible is only in the title of table I on pages 1, 3 and 5. The vehicle group is in a merged left-hand column that linear PDF text extraction drops silently: pypdf returned the 21 clause rows of page 1 with no indication that they belonged to three different vehicle groups, and the three orphan values 20% / 30% / 10% floating at the end of the page were all that survived of another table entirely.
Two rules follow:
- Extract tables positionally, not linearly. If the reader cannot see a merged row-header column, it cannot see the layer axis. Cross-check the cell count: 9 grids × 7 × 6 = 378.
- Fill in layer_model before counting anything, and reconcile tariff_layers and rate_cells. A layer count alone passes when two axes have been collapsed into one.
⚠ Known gaps in this shape, against the live table columns. These are spec defects, not product defects — the model silently cannot carry the following: - conflicts: [{ "group": …, "covers": […] }] is not applyable. LBRR_COVER_CONFLICTS has no group column at all (LBRR_COVER_CONFLICT_ID, BASIC_PRODUCT_CODE, COVER_CODE, OBJECT_CODE). The spec inherited a conflictGroup that does not exist. A conflict is a flat per-cover row. - No object scoping on business rules. LBRR_COVER_DEPENDENCY, LBRR_COVER_CONFLICTS and LBRR_LIMIT_RESTRICTIONS all carry OBJECT_CODE. On a multi-object product (travel: PERSON + GRPPA) the model cannot say "THEFT requires FIRE, for this object only". - Offer cover values lose dimension and currency. LB_COVERS_DEF has AMNT, VAR_DIM, CURRENCY, RISK_CODE; the spec's { "var", "amount" } cannot express a percentage-dimension offer value or a currency. - Covers have no liability. PR_BASIC_COVERS.LIABILITY has no home in the cover shape. - LDs cannot carry a rating table, a formula, a cap, or a per-LD ld_rule. PPA_PRODUCT_LD has LD_RATING, LD_FORMULA, LD_MAX_LIMIT and its own LD_RULE that overrides the version's. The spec's ld entry has only rate_dim/rate_base/order/prose rule, and its type admits no SR (short rate), which is a real LD_TYPE. - ld.rate cannot round-trip. SRD_ANLT.PPA_PRODUCT_LD has no LD_RATE column — verified the columns are CORRECTION_ID, VERSION_ID, COVER_ID, LD_CODE, LD_NAME, LD_TYPE, LD_SIGN, LD_ORDER, LD_RATING, LD_RATE_DIM, LD_RATE_BASE, LD_FORMULA, LD_CURRENCY, LD_RULE, MAXDF_EXEMPT, LD_MAX_LIMIT. A rate lives in a rating table named by LD_RATING, or in LD_FORMULA. So the spec's literal "rate": 10 is a document-side value with nowhere to land: it must become a rating table at apply time. Keep it in the model — it is what the source says — but do not expect an export to produce it. - PPA_PRODUCT_LD is keyed on COVER_ID, not a cover code, and product-scope LDs still get one row per cover. Reconcile LD codes, not LD rows: one observed version had 134 rows for 11 codes. - Cover rates carry no underwriting caps. PPA_PRODUCT_COVERS has DISCOUNT_AMNT_MAX / DISCOUNT_PRC_MAX and RENEWAL_AMNT_CAP / RENEWAL_PRC_CAP; cover_rates has only min/cap.
reconciliation — why it is mandatory
The one failure this agent is known to have is miscounting: three covers where the document says four, the wrong number of tariff layers, a discount silently dropped. It is not a subtle failure — but it is invisible unless someone counts.
So the model states the count both ways, with the evidence. A mismatch is a stop, not a warning. Resolve it or record it in unresolved; do not proceed past it.
Count at least: objects · covers · cover definitions · loadings/discounts/taxes · pricing factors · tariff layers or rate tables · offers.
Count distinct cover codes, not database rows
Exporting a live product shows it has 28 rows in PR_BASIC_COVERS but only 17 distinct cover codes, across three objects (HOME, CONTENT, PROP). THEFT_DAMAGE appears three times, once per object; FIRE, FLOOD, EARTHQUAKE, ROBBERY, LIABILITY and others appear twice.
That is the documented shape — PR_BASIC_COVERS is one row per product × cover × object — but it means a cover in a source document maps to N rows, not one. So:
- reconcile the document's cover count against distinct cover codes, never against row count;
- model each cover once, with the list of objects it applies to, and let the apply stage fan it out;
- a document listing 13 covers producing 28 rows is not a defect. Comparing 13 to 28 and "fixing" the difference is.
The same fan-out applies to anything keyed on basic_cover_id — definitions, risks and events are per cover-row, so they multiply too.
Check whether the product already exists before modelling it as new
Being handed a template does not mean the product is new — a template headed Product_Template_Bulstrad_3602_ described a product that already existed with 20 cover rows, 40 factor rows and 11 DEPLOYED PPA versions — the template was a revision, and the real deliverable was a delta of about four rows, not a build of thirteen covers. One query answers it: SELECT … FROM SRD_IPROD.PR_BASIC_PRODUCTS WHERE BASIC_PRODUCT_CODE = '<code>'.
When it does exist, put the live counterpart on each element (live_code, live_evidence, live_match: exact_name | semantic | absent) rather than silently adopting live values into the model's own fields. Adopting a live code as the document's answer is a decision for the operator.
Map covers by translated name, not by code
A document's cover codes are the business's naming and often share nothing with the platform's. The authoritative join is the Bulgarian label: SRD_IPROD.SR_MESSAGES_LNG where MSG_ID = 'LT_COVER_CODE_<code>' and LANGUAGE = 'BG'. (SRD_IPROD.LT_COVER_CODE holds only ID, DESCR_LINK — the text is not there, and SRD_SYS.MESSAGES does not carry these keys.)
This is not a convenience. Observed on a personal-accident product: the live convention was A_ = accident-caused and bare = illness-caused, but A_DRUGEXP was the illness drug-expense cover and A_MEDEXP the accident one. Mapping by code prefix inverts that pair; only the names resolve it.
The source document is the reconciliation target, not the live product. That template's own lists 13 covers; the live product has 17 distinct cover codes. Both numbers are correct — a live product accumulates covers a given revision of the template does not mention. Reconcile against the document you were handed, and report the difference from live as a finding rather than closing it.
Sections the shape above does not show
The block above is illustrative, not exhaustive. Three things a real product carries have no slot in it, and export_product.py already emits two of them — so a model without them is lossy:
- documents and annex_types — top-level lists, matching PR_BASIC_PRODUCT_DOCUMENTS and PR_ANNEX_TYPES. The exporter produces both.
- limit_restrictions — top-level, not nested under offers. LBRR_LIMIT_RESTRICTIONS is scoped by productCode, so a relationship like theft ≤ 10% of fire holds whether or not the product has any offers at all. Keep the per-offer restrictions for restrictions that are genuinely offer-specific.
- ld_constraints — the rules between LD rows, which no single ld entry can hold: a cap on the total of a set of discounts, and mutual-exclusion groups. Templates state these in the discounts sheet's Приложимо условие column and in an extra numbered row that is a cap rather than a discount. Give each ld entry an exclusion_group, and keep the caps here:
"ld_constraints": [
{ "key": "CAP_TOTAL_DISCOUNT", "kind": "cap",
"applies_to": ["DISC_A", "DISC_B"], "max_total": 0.40,
"unit": "fraction_of_base_premium", "rule": "…", "source": "…" },
{ "key": "EXCL_SI_BAND", "kind": "mutually_exclusive",
"applies_to": ["DISC_1", "DISC_2"], "selector": "sum insured under COVER_X",
"rule": "…", "source": "…" }
]
Dropping these is a silent behaviour change: the LDs all exist and the apply looks clean, but the product will stack discounts it is not allowed to stack.
A model element whose lookup code is not in the sources carries code: null, not a plausible slug. That is deliberate — a null stops the apply, whereas an invented code gets written. The cost is that diff_model.py keys ld on (code, scope), so several product-scope LDs with null codes collide into one identity and diff poorly. Add a model-local key alongside; the null stays.
unresolved → MISSING.md
Generated, not hand-written. .claude/rules/missing-information.md has the four fields and the three cases. blocking: true means the apply cannot start.
Round-trip
export_product.py produces this shape from a live product; diff_model.py compares two models semantically — ignoring surrogate IDs and ordering, reporting differences in meaning. That is the acceptance test (docs/01-REQUIREMENTS-FROM-MEETING.md R8).
If a real product cannot be represented in this shape without losing something, the spec is wrong — extend it and say so. The model is meant to be lossless with respect to configuration meaning.
rating-tablesA product's rates live in SRDANLT.PPAPRODUCTFILES — FILENAME is the key, CONTENT is a1,808 words
Rating tables — creating a product's own rates
Covers building a tariff from documents (a 2-D grid, conditional LDs, and a first compile from nothing). Proven end to end: authored a grid, bound it to a cover, regenerated, and the premium changed to exactly the specified figure. Every step below was executed, not inferred.
A product's rates live in SRD_ANLT.PPA_PRODUCT_FILES — FILE_NAME is the key, CONTENT is a JSON CLOB. A cover or LD does not hold rates; it holds the file name:
- PPA_PRODUCT_COVERS.COVER_BP_RATING — the cover's base-premium grid
- PPA_PRODUCT_LD.LD_RATING — a loading/discount's grid
This indirection matters: two products can point at the same file. Convenient when deriving, and a trap — editing that file changes both products. A product with its own tariff needs its own files.
The four steps
1 · Author the grid — saveLdFile, payload {"ruleFileLD": { …the grid… }}.
A flat rate:
{
"ldFileName": "<PRODUCT>_<COVER>_<something>_EUR",
"ldRuleType": "singleRow",
"ldFileRules": {
"ruleItems": [], "applyAlways": true, "modifierAmount": "120.00",
"defaultValue": null, "rows": [], "columns": [], "ruleTable": ""
}
}
modifierAmount is a string. The only rule on the name is no spaces — it is rejected outright. Use a name that says which product it belongs to; a file named after the product you copied from is how you end up editing someone else's rates.
For a rate that varies, ldRuleType: "multipleRow" with rows, columns and a ruleTable CSV; for a conditional flat rate, applyAlways: false with ruleItems. Shapes and the CSV layout are in analytics-ppa.md.
2 · Bind it — saveLdRating, with ldFileName plus exactly one of:
- coverId → writes COVER_BP_RATING on that cover
- correctionId → writes LD_RATING on that LD
Neither, and it refuses: "Neither 'correctionId' nor 'coverId' passed!". Note covers are per object — a cover offered on two objects is two rows, and each needs binding.
3 · Compile — genRuleTable. Requires the version at DONE or CONFIRMED, so a deployed version must go back through ppaDoneRevert first. Payload is double-nested: {"content": {versionId, ldRule, taxRule, company, country}, "storage": null}.
4 · Redeploy — ppaProductVersionsUpdate with versionStage: "DEPLOYED".
Then quote. Changing a rate and seeing the premium move by exactly that amount is the tightest feedback loop available, and it needs no UI.
Editing rates on a live product
genRuleTable compiles the grids into RATING_TABLE_FILES; the engine reads the compiled artefact, not the source grid. So editing a grid file changes nothing until you recompile and redeploy. A rate that "didn't take" is nearly always a missing step 3 or 4, not a bad grid.
Reverting a DEPLOYED version to DONE to recompile means it is briefly not deployed. On anything shared, do it deliberately — while a version is not DEPLOYED, nothing resolves for pricing.
The grid axis is the factor's NAME, not its code
rows / columns and every CSV label are matched against PPA_FACTORS.PRICING_FACTOR_NAME for that product — not against PRICING_FACTOR_CODE.
This is easy to miss because on most factors the two are equal, so the distinction never shows. It shows on the ones where a human typed a readable name: a grid axis reading like a sentence is that factor's name, and its code is something else entirely. Check a live grid against its product's PPA_FACTORS rows if you want to see it:
SELECT PRICING_FACTOR_CODE, PRICING_FACTOR_NAME FROM SRD_ANLT.PPA_FACTORS
WHERE PRODUCT_CODE = :code AND PRICING_FACTOR_NAME <> PRICING_FACTOR_CODE
A mismatch fails as a bare Python KeyError naming the axis — Internal error: '<AXIS>' — which reads like the factor is missing when it is present and merely named something else.
Set PRICING_FACTOR_NAME = PRICING_FACTOR_CODE on any factor you intend to use as a grid axis. That is already the case for 861 of the 1 778 live PPA_FACTORS rows, it removes the mapping entirely, and it makes the grid readable. Descriptive labels belong in IPAL on DESCR_LINK, where they are translated; the PPA name is a lookup key, not a caption.
An axis can carry SEVERAL factors — that is how a 4-D tariff fits in one file
rows and columns are lists, and rows may name more than one factor. A cover has exactly one COVER_BP_RATING, so a tariff keyed on four things — say deductible × vehicle category × clause × age — is not four files: it is one grid with a three-factor row axis and a one-factor column axis.
{"rows": ["DEDUCTIBLE", "EV_CATEGORY", "BATT_CLAUSE"], "columns": ["BATT_AGE"]}
The CSV then carries one leading empty cell per row factor in the header, and one label cell per row factor on every data row:
,,,BATT_AGE in (UP_TO_2),BATT_AGE in (A2_4),…,BATT_AGE (Other)
DEDUCTIBLE in (0),EV_CATEGORY in (PASSENGER),BATT_CLAUSE in (DEGRAD_ONLY),0.54,0.71,…
DEDUCTIBLE in (0),EV_CATEGORY in (PASSENGER),BATT_CLAUSE (Other),…
Every axis still needs its (Other) catch-all, and the row labels are the full cartesian product including the catch-alls — 3 values + (Other) on each of three factors is 64 rows, not 27. Live precedent: 3443_LIABCR_1B_9538_EUR, whose rows is ["LOAD_CAPACITY", "VEHICLE_TYPE"].
a 3-factor row axis × 1-factor column axis compiled and priced 135 published rates to the cent.
A row-axis level set collapses a whole dimension. A tariff whose rows are keyed on a grouping of a factor's values — nine vehicle types falling into four tariff groups — does not need a derived group factor, which would need a filter to compute. Write the group as a level set on the underlying factor: CP_VEH_TYPE in (PASSENGER;MINIBUS_9_16;TRUCK_UP_35). Four such rows plus a catch-all, crossed with a 7-value clause axis and a 6-band numeric age axis, is one file of 41 rows carrying 168 published rates. Confirmed by quoting every cell of a three-variant tariff, each to the cent.
A CATEGORICAL axis needs an (Other) catch-all. A numeric one does not.
The two axis kinds are validated differently, and treating them the same wastes a column or fails a deploy:
- Categorical (PPA_FACTORS.DATATYPE anything but NUMBER/IV): every live multi-row grid ends the axis with a catch-all — HOME_TYPE (Other), Vessel Type (Other). The form is <axis> (Other), with no in. Value labels are <axis> in (VALUE), and a level set is semicolon-separated: <axis> in (A;B;C).
- Numeric (NUMBER/IV): the bins must exactly tile the axis, and genRuleTable asserts it (Nodes not consistent with bins!). Anchoring the ends with (<= n] and (> n) makes the tiling complete, so no (Other) is needed and adding one is what breaks the tiling. Verified on dev
CAR_AGE (<= 3], (3 - 5], (5 - 7], (7 - 10], (10 - 13], (> 13) compiled and then priced all six bands correctly, with no catch-all.
⚠ A categorical axis whose level values are all digits fails to compile
genRuleTable dies with 'numpy.float64' object has no attribute 'upper' when a LIST-typed (i.e. STRING) axis has level values that are purely numeric — DEDUCTIBLE in (0), in (200), in (400).
The mechanism, read from the source: gen_rule_table.py saves the compiled CSV, then table_interpreter.py reloads it with a bare pd.read_csv and no dtype=str. That axis's "<CODE> upper" column holds digit-strings for its own rows and NaN for every other cover/LD row in the concatenated table, so pandas infers the column as float64. get_nodes (table_utils.py:37-38) then calls ls.upper() on a numpy.float64 and raises. The exception is re-wrapped as Deploying configuration failed with exception ..., so it does not name the axis.
Alphabetic level values do not trip it, which is why most products never meet this.
Two fixes, and prefer the first:
- Make the level codes non-numeric — D000 / D200 / D400 rather than 0 / 200 / 400, with the human-readable amount in PRICING_FACTOR_VALUE_NAME. This removes the hazard at source and needs no invented rates. Used on 9951.
- Add an (Other) entry to that axis, which puts one alphabetic value (OTHER) in the column and stops the coercion. Cheap, but on a cover tariff grid it obliges you to put a rate in a cell the document does not give you — see the catch-all note above.
The tell: the axis is DATATYPE LIST in PPA_FACTORS and every one of its values matches ^[0-9.]+$. Check that before deploying, not after.
The spaced form <axis> (<= n] / <axis> (a - b] / <axis> (> n) parses; so does the tighter <axis>(<n] seen in older live files. get_number takes the first number in the label, so the spacing is free.
For a closed, required value list the categorical catch-all is unreachable, but it still has to be there. Give it a rate that fails expensive rather than free — an unreachable cell that silently prices at zero is a worse failure than one that overcharges. On a loading or discount grid the opposite holds and the catch-all is 0: there the zeros ARE the condition, and the LD is meant to contribute nothing where it does not apply.
A conditional LD is a grid, not a conditional single row
The obvious shape — ldRuleType: "singleRow", applyAlways: false, ruleItems: [...], modifierAmount — does not compile. Without defaultValue the validator refuses ("defaultValue is needed if rows and columns are null"); with it, genRuleTable dies reading an empty ruleTable ("No columns to parse from file"). Files in that shape exist in the estate, but nothing recompiles them.
⚠ rows must be an empty list [], not [""]. A "" axis name makes genRuleTable fail with the bare, unhelpful Internal error: '' — which looks like a missing factor and is a malformed axis.
Express the condition as a one-dimensional grid over the driving factor instead — the same multipleRow mechanism the cover tariffs use, with a single row labelled =>:
{"ldRuleType": "multipleRow",
"ldFileRules": {"ruleItems": [], "applyAlways": null, "modifierAmount": null,
"defaultValue": null, "rows": [], "columns": ["<DRIVER>"],
"ruleTable": ",<DRIVER> in (A),<DRIVER> in (B),<DRIVER> in (C),<DRIVER> (Other)\n=>,5.00,0.00,0.00,0.00\n"}}
⚠ The axis above is deliberately a placeholder. An earlier version of this file used CLM_HIST with values NONE/ONE/TWO_PLUS; the live CLM_HIST factor on dev carries global values Y/N (PR_PRICING_FACTOR_VALUES with a null PRODUCT_CODE). Copying an illustrative axis as if it were a real one is how a product ends up with two incompatible value lists in the same dropdown — check the factor's live values before reusing its code.
The zeros are the condition: the LD exists on every policy and contributes nothing where it does not apply. Numeric drivers use the interval form — NUM_PPL_GRP(9-24] is exactly "10 to 24" for an integer count, which is how a >= is expressed when the operator set has only < and >.
⚠ An LD authored applyAlways: true applies to every policy, unconditionally. It will not error, and a quote will look plausible: four such LDs on one product netted to exactly the right total by cancellation, making a configuration that was wrong in four places produce the correct premium. Only a case that separates them catches it.
schema-quirksThe previous implementation carried these as hardcoded correction maps664 words
Schema quirks
Observations, not truth. These were true when someone hit them. The schema moves. When one of these contradicts what you just introspected, the introspection is right — fix this file.
The previous implementation carried these as hardcoded correction maps (PRIMARY_KEY_CORRECTIONS, SCHEMA_CORRECTIONS). They are recorded here as knowledge instead, because a map that silently patches a name hides the day the name changes.
Field names that are not what you would guess
| | Schema | Not this | But this
| prPricingFactor | basicProductId | productCode
| prPricingFactor | genericName | pricingFactorName
| prPricingFactorValue | basicProductId | productCode
| prPricingFactorValue | genericName | pricingFactorValueName
| prPricingFactorDependent | basicProductId | productCode
| prBasicProduct | productCode | basicProductCode
| prBasicCover | productId | basicProductId
| prBasicCoverDef | productId | basicProductId
The pattern: product-level entities key on basicProductId; pricing-factor entities key on productCode. Knowing the pattern beats memorising the table.
Natural keys
| | Schema | Natural identity
| prBasicProduct | basicProductCode
| prBasicCover | basicProductId + coverCode
| prPricingFactor | productCode + pricingFactorCode
| prPricingFactorValue | + pricingFactorValue
| prPricingFactorDependent | productCode + main code/value + dependent code/value
| srMessage | msgId (a string) — created through the message path, not as an ordinary record
Surrogate IDs are sequence-defaulted; do not supply them.
Table names the inherited documentation got wrong
Found by exporting a real product. The pattern is worth more than the list: table names are sometimes singularised in prose, so confirm a table exists with --tables before building a query on a name you read in a document.
| | Documented as | Actually
| PR_BASIC_PRODUCT_DOCUMENT | PR_BASIC_PRODUCT_DOCUMENTS
| PR_ANNEX_FACTOR | PR_ANNEX_FACTORS
And one keying mistake: PR_ANNEX_TYPES keys on PRODUCT_CODE, not BASIC_PRODUCT_ID, and its code column is ANNEX_TYPE — there is no SOURCE_TRANSACTION_CODE column and no LT_SOURCE_TRANSACTION_CODE lookup step. This extends the productCode vs basicProductId split below beyond the pricing-factor family.
Known backend inconsistencies
- prBasicCoverRisk's key is prCoversRiksId — a typo in the backend, spelled that way in the schema. Match it; do not "correct" it.
- PrBasicCoverDefList is spelled PrBasicCoverDeflist (lowercase l) in the schema.
Read-only computed fields
Anything ending in Text — coverCodeText, objectCodeText, ldCodeText, eventCodeText, riskCodeText — is generated from lookup translations for display. Never send them in a create, update, or copy payload; they are rejected. This bites hardest when copying a queried record straight back as a mutation input: strip the *Text fields first.
Oracle errors and what they usually mean here
| | Error | Usually
| ORA-02291 | FK violation — a lookup code does not exist yet. Find the right one, or create it.
| ORA-00001 | duplicate key — the row already exists; read it before deciding to update
| ORA-02292 | children exist — delete in reverse dependency order: events/risks/definitions → covers → names → product
| ORA-12899 | value too large — check the column width (ldType is 3 characters: TAX, not TAXLOAD)
| ORA-02290 | check constraint — an invalid enum: ldType ∈ {LD,TAX} (never DIS), coverType ∈ {COV,OPT}, ldSign ∈ {1,−1}, varDim ∈ {V,P}
| ORA-00904 | invalid identifier — the column is spelled differently than you assumed. PRICING_FACTOR_RANGE_FROM, not RANGE_FROM.
Diagnose, correct, retry once or twice — then stop and report. A third identical attempt is a loop. .claude/rules/writes.md.
Reading a CLOB through the Oracle MCP needs TO_CHAR
Selecting a CLOB column directly (e.g. SRD_ANLT.PPA_PRODUCT_FILES.CONTENT) fails with Error serializing to JSON: InterfaceError: DPY-1001: not connected to database — the LOB is read lazily, after the transport has moved on, so it reads like an outage and is not one. The connection is fine; a non-LOB query against the same schema answers immediately.
Wrap it: SELECT TO_CHAR(SUBSTR(CONTENT, 1, 4000)) FROM .... DBMS_LOB.INSTR(CONTENT, '<needle>') > 0 works for searching CLOBs in a predicate.
shared-vocabularyObject codes, cover codes and factor codes are platform vocabulary, not product-local names. They1,510 words
Shared vocabulary — reuse the code, don't mint a new one
Object codes, cover codes and factor codes are platform vocabulary, not product-local names. They are not scoped to a product: the same PERSON, the same FIRE, the same DURATION appear across many products, and downstream systems, rating grids and UI components key on them.
So the default when configuring a product is to reuse the existing code. Minting a near-duplicate (PERSON_2, FIRE_NEW, DURATION_D) is the most common way to make a product that configures cleanly, prices correctly, and then behaves oddly in the UI or fails to map downstream — because the component and the mapping were keyed to the original.
Check before you create — every time
Never assume a code is new. Ask:
-- factor: which products already use this code, and is it global?
SELECT PRODUCT_CODE, DATATYPE, POLICY_TABLE, SALE_STAGE
FROM SRD_IPROD.PR_PRICING_FACTORS WHERE PRICING_FACTOR_CODE = :code;
-- cover: which products, and against which object?
SELECT p.BASIC_PRODUCT_CODE, c.OBJECT_CODE, c.COVER_TYPE
FROM SRD_IPROD.PR_BASIC_COVERS c
JOIN SRD_IPROD.PR_BASIC_PRODUCTS p ON p.BASIC_PRODUCT_ID = c.BASIC_PRODUCT_ID
WHERE c.COVER_CODE = :code;
-- object: is this an established object type?
SELECT OBJECT_CODE, COUNT(DISTINCT PRODUCT_CODE) FROM SRD_IPROD.PR_PRICING_FACTORS
WHERE OBJECT_CODE IS NOT NULL GROUP BY OBJECT_CODE ORDER BY 2 DESC;
A hit means reuse it. Read how the busiest existing user configures it and follow that shape.
The three tiers, and how to tell them apart
Run the object query above and the distribution speaks for itself:
- A small core used by most products — duration, payment frequency, the policy-level plumbing. Never redefine these. A new product almost always wants the existing rows.
- A middle band shared by a family — the covers and factors of a line of business. Reuse within the family; this is where a genuinely new product legitimately joins an existing vocabulary.
- A long tail used by exactly one product — genuinely product-specific. This is the only tier where inventing a code is right.
Most factor codes live in the tail, so new factors are normal — but the tail is not where the policy-level plumbing lives, and that is exactly what gets wrongly re-minted. Run the query rather than guessing which tier you are in; the balance shifts as products are added.
Cover codes and object codes are not merely conventions — they are enforced
This is the single most expensive thing to discover late:
PR_BASIC_COVERS.COVER_CODE --FK--> LT_COVER_CODE (PR_BASIC_COVERS_COVER_CODE_FK)
PR_BASIC_COVERS.OBJECT_CODE --FK--> LT_OBJECT_CODE (PR_BASIC_COVERS_OBJECT_CODE_FK)
Both lookups are two-column (ID, DESCR_LINK) and the gateway has no write command for any LT_* table — zero commands matching ltCover* in the live registry. So from the configuration path, the set of usable cover codes and object codes is closed. A cover code that is not already in LT_COVER_CODE cannot be inserted, and PR_BASIC_COVERS will reject it with ORA-02291.
Check the lookup before accepting a document's cover codes:
SELECT ID FROM SRD_IPROD.LT_COVER_CODE WHERE ID IN (:the document's codes);
SELECT ID FROM SRD_IPROD.LT_OBJECT_CODE WHERE ID = :the document's object;
Any code that comes back missing is not configurable. Three ways out, in order of preference:
- Map the document's cover onto an existing code whose meaning matches. This is nearly always right, and it is why the reuse rule exists — the platform enforces it whether you agree or not.
- Add the lookup row yourself. The LT_* tables have no gateway create command, but they are writable in SQL — pcwrite.apply("LtCoverCodeCreate", …) and its siblings. Create the label first: DESCR_LINK is a foreign key to the message table.
- Escalate only if the account genuinely cannot write the table. Confirm that with a no-op (INSERT … WHERE 1=0) rather than a privilege view, which misses grants held through roles.
⚠ Do this check during modelling, not during apply. Everything else — factors, rating, quotes — configures and prices perfectly without a single IPAL cover row, because PPA_PRODUCT_COVERS has no foreign key to IPAL. So a product can reach a correct, reconciled premium and still have no covers, and nothing surfaces the problem until issuance. Confirm the cover codes exist before you build.
Object codes carry the same constraint plus one more: they are coupled to frontend components. The UI renders the object form from the object code, so an unrecognised one has no form even if the lookup row exists. Choosing PERSON for a person product is not a naming preference — it is what makes the object step render at all.
A cover code does not imply one object
A cover code can appear against different object codes on different products — a substantial number do. So COVER_CODE alone does not identify a configuration: the pair (COVER_CODE, OBJECT_CODE) does. When you copy a cover's setup from another product, confirm it was configured against the same object, or you inherit a shape that does not fit yours.
What sharing means when you write
- Reusing a code is free. Editing a shared row is not. PR_PRICING_FACTOR_VALUES and PR_PRICING_FACTOR_DEPENDENT rows with a null PRODUCT_CODE are shared by every product — changing one to suit your product changes it for all of them. To differ, add a product-scoped row; never edit the global one.
- Same for translations: a DESCR_LINK is a global message code. Reuse the code and you inherit its text everywhere; edit that text and you rebrand every product using it (translations.md).
- Reuse the code, not the pointer. Sharing a factor code is intended. Sharing another product's rating file or message row usually is not.
Reusing a code is not inheriting a row
⚠ The most costly misreading of this page. Reusing a factor code means not minting a near-duplicate — it does not mean the product gets that factor for free.
A product still needs its own PR_PRICING_FACTORS row for every factor it uses, with its own allowed values, even for the most universal ones. A product with no row for a shared factor does not inherit it; it simply does not have it, and the field renders as an empty dropdown the operator cannot fill.
Observed: a product configured without its own rows for the duration and payment-frequency factors reached the UI with three empty required dropdowns and could not be quoted, while every one of those factors existed and was in use by dozens of other products.
So: reuse the code. Scope the rows. Never edit a global row.
A useful dividing line: object attributes vs questionnaire answers
Reuse and minting stop being a judgement call once you ask what kind of thing the factor is:
- An attribute of the insured object — the vehicle's age, make, engine capacity, seats, fuel — is already captured on the object card. Reuse the platform's factor even when the document phrases it as a question, because a second factor over the same fact can contradict the first, and the operator would answer it twice. A document band such as "up to 3 / 3–5 / 5–7 years" maps onto the numeric attribute exactly; and where the same document bands the same quantity differently in two sections (six bands for the main rate, three for an add-on), only the numeric attribute can serve both — a six-value list cannot.
- An answer about the risk, the policyholder or the history — usage, claims count, no-claims years, service arrangement, region — is the document's own vocabulary. Mint it when the platform's near-neighbour carries different values, and say so.
Watch the party, not just the name: a platform factor for the policyholder's region or age is not the same as a tariff rule keyed on the owner's or lessee's, which differ on a leased vehicle. That difference is a reason to mint, and it should be stated rather than glossed.
The rule
Reuse the code. Scope the value. Never edit a global row to suit one product.
Reusing a code whose values are GLOBAL merges two value lists
reuse-before-create.md says reuse the code and scope the value. That works when the existing values are themselves product-scoped. It does not work when they are global (PRODUCT_CODE IS NULL), because a global value applies to every product — adding your own alongside gives the operator one dropdown containing both sets.
So the check before reusing a factor code is not "does the code exist" but:
SELECT NVL(PRODUCT_CODE,'(global)'), COUNT(*),
LISTAGG(PRICING_FACTOR_VALUE, ',') WITHIN GROUP (ORDER BY UI_ORDER)
FROM SRD_IPROD.PR_PRICING_FACTOR_VALUES
WHERE PRICING_FACTOR_CODE = :code GROUP BY PRODUCT_CODE
- no global rows (e.g. DEDUCTIBLE — every live row carries a product) → reuse the code, add your product-scoped values.
- global rows whose meaning matches (e.g. CAR_SUBTYPE, 17 global values) → reuse both; add nothing.
- global rows whose meaning does NOT match (CLM_HIST is global Y/N, while a product needed NONE/ONE/TWO_PLUS) → mint a new code. Reusing it would put five values in one list. This is the one case where creating a near-neighbour of an existing code is the right answer, and it should be said out loud in the plan.
testing-a-productA configuration that has not produced a premium is a hypothesis. This is how you turn it into1,246 words
Testing a product — proving a configuration works
Pricing a real deployed product end to end. The gateway details below were found by probing; they are the kind of thing that changes, so if one stops working, re-probe rather than assume the capability is gone.
A configuration that has not produced a premium is a hypothesis. This is how you turn it into evidence — without the UI, repeatably, and without writing anything.
Before anything else: is the gateway configured?
abacus.py needs PC_GATEWAY_URL and OIDC client credentials (or PC_TOKEN), read from the environment or from .pc-endpoints.json at the workspace root. That is the gateway configuration, and it is entirely separate from database access.
Database access needs no credentials here at all: pcmcp.py and the mcp__oracle__execute_query tool both reach the scoped Oracle MCP over HTTP (PC_ORACLE_MCP_URL). If SQL fails, the question is whether that MCP instance is running — not whether a password or the VPN is right.
.pc-endpoints.json is gitignored, so a fresh checkout has no gateway configuration and every gateway call fails with PC_GATEWAY_URL is not configured for '<env>', including read-only ones. abacus.py check can return 0/5 on a clean workspace while database reads were unaffected — the two paths fail independently.
That failure means numeric verification (level 2) is unavailable, not that the product is broken. Say so and stop rather than working around it; ask the operator for PC_GATEWAY_URL_<ENV> plus PC_IDENTITY_ISSUER_<ENV> / client credentials, or a PC_TOKEN.
The one test that matters
uv run scripts/abacus.py quote --env <env> \
--product <code> --template <template> --object <objectType> \
--covers <A,B> --factors '<json>' --date YYYY-MM-DD
getRates computes and stores nothing. It is safe to run as often as you like, which makes it the inner loop of every verification: change configuration → quote → read the breakdown → change again.
A successful response is the full computed breakdown for one resolved version: one BP row per rated cover, one row per LD and TAX code, and a WP roll-up per cover. Check the arithmetic — under a SUM rule, WP should equal BP plus the LD rows. If it does not, you have found something real.
⚠ getRates ignores selectedCovers. It prices every cover the product has and returns a BP and a WP row for each; which covers a policy actually carries is decided by the caller, not the engine. So a quote's totals are the whole catalogue, not the case you asked for — reconcile per cover, which is stricter anyway — the same five BP rows came back whether one cover or two were named.
Build the factor set from the declaration, not from guesswork
The engine needs every factor the grids reference, not just the ones belonging to the covers you selected. Deriving them is a query, not an act of memory:
- the declared factors for the product — SRD_ANLT.PPA_FACTORS, keyed on PRODUCT_CODE
- a legal value for each — SRD_ANLT.PPA_FACTOR_VALUES, keyed on FACTOR_CODE (there is no product column on the values table; it is a global code→values catalogue)
Then override the handful that carry the actual case you are testing: the insured values, the duration, the payment frequency.
Always send the whole duration triple — days, months and years together. The real caller injects all three unconditionally, and short-rate handling fails without days. 365/12 are the magic annual values.
The errors are not equally helpful — read them literally
This is worth knowing before you interpret a failure as "the configuration is broken":
| | What comes back | Means
| 400 Missing features {A, B, C} | the clean case — it lists exactly what to add
| 400 Exception (Missing factor 'X') occured when processing short rate | one factor at a time, from the short-rate path
| 500 Internal error: 'X' | also a missing factor — a bare KeyError surfacing through the catch-all. It names one factor and stops.
| 200 with no rate rows | the dangerous one — see below
| 400 No configured premium for product P with effective date before or at D | no DEPLOYED version resolves for that date
So a 500 here is usually not a platform fault. Add the named factor and go again.
A 200 with no rates is a failure, and the platform does not treat it as one. The caller logs a warning and produces a silently zero premium. Treat an empty rates[] as a hard failure. Usual causes, in order: no DEPLOYED version whose effective date is on or before your date; a template code that does not exist for the product (the engine falls back silently to another one); a cover code the product does not have.
Gateway mechanics
Two things that cost time to discover and will not be guessable:
- Endpoint routes live under the gateway path. Getting the base wrong returns 405 from nginx, which reads like "this endpoint does not exist" and is not.
- Every command's payload is wrapped in content. Without that wrapper the gateway answers 400 "Request has no content!" no matter what else you send.
Routes themselves are discovered, never hardcoded — scripts/abacus.py endpoints reads the live registry (SRD_SYS."EndPoints" joined to "EndpointSections"). A command missing from that listing is genuinely not registered here; a command you assumed exists may simply be named differently. abacus.py check smoke-tests the read surface and tells you whether the rating side is reachable at all before you start diagnosing configuration.
The three levels, and what each proves
- Structural — the rows are there. Proves nothing about behaviour.
- Numeric — quote returns a premium, and you reconcile a figure against the source document. This is where most real defects surface: a rate that is out by a factor, a discount applied twice, a cap that never binds.
- Operational — the product works for a person: visible to the right role, covers selectable, dependent dropdowns populated, an offer produced. Only the UI shows this.
Level 2 is automatable and cheap; run it constantly. Level 3 is slow; run it before declaring done. Say which level you reached. "Structurally verified, priced correctly, not yet exercised in the UI" is an honest and useful result.
What a quote cannot tell you
It prices what you ask for. It does not know whether the operator can reach the product, whether the covers are selectable in the right combinations, whether a required factor has an empty dropdown that blocks the quote, or whether the offer packages make sense. Those are configuration questions the rating engine never sees — and they are exactly where an empty dependent-factor list does its damage (pricing-factors.md).
A zero premium has two very different causes
Before diagnosing a pricing problem, establish whether the engine ran at all. A zero or missing premium means either the engine priced zero, or it was never invoked — and those lead to opposite investigations.
The rating engine writes a log row per pricing call (ABC_POLPR_LOG on the analytics side). No rows for the period means the engine was never called, so the fault is upstream: no covers attached, no offer resolved, a date outside validity, a cover selection that never reached the caller. Looking at rates, grids or factors in that state wastes the cycle.
Check it first. It is one query and it decides which half of the system to look at.
three-domainsplaces, and what an operator can actually sell is the intersection.764 words
The three configuration domains
There is no single product registry. A product's behaviour is decided in three independent places, and what an operator can actually sell is the intersection.
IPAL (portal) ABACUS / SRD_ANLT (tariff) Offers (packaging)
what the operator sees → what the premium is → what is offered and how
SRD_SYS / SRD_IPROD / SRD_ANLT.PPA_* SRD_IPROD.LB_* / LBRR_*
SRD_INTEGR / SRD_PR ABC_ACCESS.CFG_ABC_*
| | Domain | Owns | Guide
| IPAL | catalogue, visibility, structure, inputs, rules | ipal-configuration.md, pricing-factors.md
| ABACUS / SRD_ANLT | rating, versions, loadings/discounts/taxes | analytics-ppa.md
| Offers | packaging, presentation, cover eligibility (LB_*) | offers-engine.md
All three are this agent's to configure. Anything beyond them — the contract itself, printing, nomenclature, policy administration — is not; see boundaries.md.
Why "partially determined" is the right answer
Given a complete ABACUS configuration, the IPAL side is only partially determined — which is why the source documents stay a first-class input:
- Derivable from the tariff: which covers are rated, which factors the rating keys on, the LD structure, version and template shape.
- Not derivable: cover names and how definitions should read, offer packaging, and everything in the customer requirements document — "only users from group 6 see this product", when a cover may be taken, and similar. Those land in Routes."AllowedRoles", in PR_PRICING_FACTOR_VALUES.ATTR3, and in the offers tables.
So: derive what is derivable, read the rest from the requirements, and list what is in neither.
Product populations — the shape of the estate
Counts drift, so query them rather than quoting them; the shape is the point:
| | Bucket | Meaning
| ABACUS + IPAL | the real IPAL product set — sellable in the portal, priced by ABACUS
| ABACUS config, no IPAL issuance | configured but not issuable in IPAL — skip when mirroring
| IPAL stack, no PPA config | issuable with no ABACUS tariff version — an anomaly, worth a ticket
| issued outside the portal | not sellable through IPAL and not a model for one
The practical use: when told "configure product X like product Y", check that Y is in the first bucket before copying from it.
Ownership boundary
A configuration row or a data fix needs no change request. Code or a user exit does — IPAL code goes through the Ablera dev process.
A recurring manual per-policy fix is a signal that a configuration cell is wrong — that is a configuration bug to escalate, not a workflow to automate.
⚠ Every factor source must resolve LOCALLY
A pricing factor's source must resolve inside the Serdica schemas. A source that resolves through a database link to another system is not permitted in a product this agent configures. Such a product cannot be priced or supported from here: its values live somewhere this agent cannot read, write, or reason about, and a remote link is an availability and ownership dependency that configuration must not create.
The table name does not tell you which you have — establish it. A source that looks like an ordinary table may not exist in Serdica at all:
-- does it exist locally?
SELECT owner, object_name, object_type FROM all_objects WHERE object_name = :src;
-- or is it a synonym over a link?
SELECT synonym_name, table_owner, table_name, db_link FROM all_synonyms WHERE synonym_name = :src;
-- and what links exist at all?
SELECT db_link, host, owner FROM all_db_links;
No local object and no synonym means the name is resolved inside PL/SQL, qualified with a database link — i.e. remotely. Run all three before relying on any source.
Where this bites in practice. SRD_INTEGR.ABC_CFG_PRICING_FACTORS maps a pricing factor to where its value is read, and its sources resolve remotely. A product configured by this agent should carry no rows there. It also feeds a different premium pipeline from the browser quote path, so populating it does not help a quote either — it only creates the dependency.
The local equivalent is PR_PRICING_FACTORS. Its POLICY_TABLE / VALUE_SOURCE name stores that exist locally in SRD_CUST — POV (POL_OBJECT_VALUES), POL_ANNEXES.<field>, POL_COVER_DEF, and the object's own OBJ_CAR.<field> attributes. Configure that one.
⚠ Pinning a constant in place of a blocked source is not a substitute. Setting PF_DFAULT_VALUE, or a fixed DEFAULT_VALUE on the factor, makes the quote resolve and silently mis-prices every non-default choice the customer can select. If a factor's only source would be remote, the factor cannot be rated from here — say so and record it rather than pinning it.
translationsAlmost nothing the operator reads is stored on the configuration row. A cover, a factor, a factor589 words
Translations — every visible name in the product
Tracing a cover label end to end, and what exists at each hop.
Almost nothing the operator reads is stored on the configuration row. A cover, a factor, a factor value, a UI group, a validation warning — each holds a DESCR_LINK, and the text lives in the message tables. Configure a product and skip this, and the UI renders raw keys.
The chain
<config row>.DESCR_LINK ──► SRD_IPROD.SR_MESSAGES.MSG_ID ──► SR_MESSAGES_LNG (one row per language)
'LT_COVER_TYPE_PASSENGERS' MSG = 'Passengers cover' BG / HY / EN
DESCR_LINK is a message code, not a numeric id — MSG_ID is the readable string you choose. This trips people expecting a foreign key to a sequence.
| | Table | Columns | Holds
| SR_MESSAGES | MSG_ID*, MSG_TYPE*, MSG*, MSG_PURPOSE | the key and its fallback text
| SR_MESSAGES_LNG | MSG_ID*, LANGUAGE*, MSG*, ID* | the text per language
MSG_TYPE distinguishes ordinary labels from errors and warnings. Use MESSAGE for product-configuration labels — it is the overwhelming default, and LABEL is rarer than its name suggests. SELECT MSG_TYPE, COUNT(*) FROM SRD_IPROD.SR_MESSAGES GROUP BY MSG_TYPE if you want the current spread.
Which languages you actually owe
Do not assume English. SR_MESSAGES.MSG is a fallback, not a translation, and the languages actually maintained are a property of the deployment — translate into the ones already in use, not the ones you expect. Ask the database:
SELECT LANGUAGE, COUNT(*) FROM SRD_IPROD.SR_MESSAGES_LNG GROUP BY LANGUAGE ORDER BY 2 DESC
The long tail is noise; the languages with comparable large counts are the ones a product owes. A product translated into only one of them is half-configured for everyone else.
Write the default text in SR_MESSAGES.MSG, then one SR_MESSAGES_LNG row per language.
Creating a label
- SR_MESSAGES — MSG_ID your code, MSG_TYPE='MESSAGE', MSG the fallback text.
- SR_MESSAGES_LNG — one row per language (BG, HY), same MSG_ID.
- Set DESCR_LINK on the configuration row to that MSG_ID.
Pick a code that says what it labels and which product owns it. Codes are global: reusing an existing one silently rebrands it everywhere it is already used.
Where DESCR_LINK appears
Not just covers. PR_BASIC_PRODUCT_NAMES, PR_BASIC_COVER_NAMES, PR_PRICING_FACTOR_VALUES, PR_PRICING_FACTOR_DEPENDENT (dependent values carry their own label), LBRR_LIMIT_RESTRICTIONS (the violation warning), and on the factor itself as UI_GROUP_DESCR_LINK and UI_HELP_DESCR_LINK.
⚠ Both PR_PRICING_FACTOR_VALUES and PR_PRICING_FACTOR_DEPENDENT carry a literal PRICING_FACTOR_VALUE_NAME and a DESCR_LINK. The literal is not translated. Use it only for values that are the same in every language (a number, a code); anything a person reads needs the link.
Two silent failures
A missing translation does not error. Labels pointing at a DESCR_LINK with no SR_MESSAGES_LNG row exist in live data today — nothing complains, and the operator sees the fallback or the raw key. So "the UI rendered" is not evidence the labels are right — read them, in each maintained language.
Cloning copies the pointer, not the text. A cloned cover points at the source product's message. It renders correctly and is wrong — the new product now displays the old product's wording, and editing that message changes both. New labels need new codes.
Checking a product's labels
SELECT n.DESCR_LINK, m.MSG, l.LANGUAGE, l.MSG
FROM SRD_IPROD.PR_BASIC_COVER_NAMES n
LEFT JOIN SRD_IPROD.SR_MESSAGES m ON m.MSG_ID = n.DESCR_LINK
LEFT JOIN SRD_IPROD.SR_MESSAGES_LNG l ON l.MSG_ID = m.MSG_ID
WHERE n.BASIC_PRODUCT_ID = :id
A null m.MSG means the code does not exist; a missing BG/HY row means an untranslated label. Both render without error.
writing-configuration⚠ Configuration is corrected by UPDATE as a working posture, not because the database refuses —1,815 words
Writing configuration — the envelopes, and how each one lies to you
Every rule here cost at least one failed attempt; the error messages are the reason this file exists, because none of them names the actual problem.
Three write paths, not one
| | What you are writing | Path
| Everything in configuration — products, covers, factors, values, messages, lookups, rating, routes | SQL, via pcwrite
| Correcting any of it | the same, with …Update — pcwrite resolves an unknown XxxUpdate to the table its XxxCreate twin writes, so every entity has an update path without naming it twice
| Compiling the rate grids (genRuleTable) | the gateway, via abacus.py — it is not a row write
⚠ Configuration is corrected by UPDATE as a working posture, not because the database refuses — DELETE is permitted on this instance's writable tables (see the note further down, and ask the instance with DELETE … WHERE 1 = 0 in mode="edit" rather than trusting any blanket claim). Correcting in place is what you want on a live product, because a delete is unreviewable and can break a policy that already references the row; tearing down a test product is the case where deleting is right. COMMANDS names only the *Create half; for a while that meant there was no way to fix a row at all, which matters more than it sounds — a value copied verbatim from a donor product and never re-parented can only be repaired in place.
Configuration writes are SQL. pcwrite.apply() takes a gateway-shaped payload and writes the table directly through the scoped Oracle MCP: idempotent, no credential, and it discovers columns, keys and constraints from the catalogue so a schema change needs no code change.
The gateway remains for the handful of operations that are not row writes — chiefly the rate-grid compile, which also resets the running engine's cache. pcwrite.NOT_SQL names them and refuses them with the reason.
The C# CRUD convention: every primary key is id
The DTO input models expose the key as id, renamed onto the real column by AutoMapper (ServiceProfile.cs, ForMember(dest => dest.XxxId, opt => opt.MapFrom(src => src.Id))). This is uniform across the whole DTO layer.
Send the column name instead and Newtonsoft silently ignores it, the DTO's Id stays null, and you get:
Unable to track an entity of type 'X' because its primary key property 'XId' is null.
which reads like a data problem and is a naming problem. Before writing an entity, check its DTO*InputModel and the ForMember line. One documented exception: DTOSrMessagesLngInputModel has a real MsgId (a foreign key) plus a long Id.
The Python (Analytics) convention: send every column
The Analytics handlers index columns directly out of the request, so a missing key is a KeyError, surfaced as Internal error: 'companyId'. It names one key at a time, so fixing them one by one is a long loop.
Do this instead: read the source row, camelCase every column, and send the whole thing — nulls, audit columns and all. Then override what should differ. Two or three iterations become one.
genRuleTable takes {"content": {…params…}, "storage": null} and that is the whole body — storage is a SIBLING of content, not inside it. Its params are versionId, ldRule, taxRule, company, country.
⚠ abacus.call passes a payload through unchanged when it already contains a content key, so the shape you write is the shape that is sent. Wrapping it a second time — {"content": {"content": {…}, "storage": null}} — is rejected with 500 Internal error: 'storage', a bare KeyError that names the key it could not find at the level it looked. Verified on dev: the double-nested form returned that 500 and the single form returned 200 Success for the same version.
Read the failure mode, not the message
| | What you get | What it means
| 450 with a message | The handler ran. Your payload reached the DTO. Fix the data.
| Timeout after N seconds | The payload was the wrong JSON kind — an array where an object was expected, or an unknown command. It failed before the handler's try block, the reply was never published, and the request waited out the endpoint's Timeout. Fix the shape.
| 200 with a body | Written.
| 400 "Request has no content!" | An Analytics command sent without the content wrapper.
A timeout is never a partial write on this path. Nothing landed; nothing is pending.
Order, idempotence, resumability
Clone in dependency order and carry a source→target id map: the product returns its id, covers return theirs, and the children of each cover must be re-parented onto what the platform returned, never onto what you predicted.
Any real clone is hundreds of steps and will fail partway. Record completed steps and their returned ids to a state file, stop at the first failure, and resume by skipping what is done. Continuing past a failure manufactures orphans — a cover that failed leaves its definitions with nothing to attach to.
Routes ids are .NET byte-swapped GUIDs
The Routes primary key is RAW(16). Read it as hex from Oracle and you get one string; read it back through the .NET API and you get another, because it serialises a Guid little-endian in its first three groups.
Oracle hex f1a160f2 64fc 435b 8a95 5b1f06c5c9ec
App GUID f260a1f1-fc64-5b43-8a95-5b1f06c5c9ec
^^^^^^^^ ^^^^ ^^^^ each of the first three groups is byte-reversed
Pass the hex form as a parentId and you get ORA-02291 ... parent key not found, which reads like the parent does not exist. It does; you named it wrong.
Do not convert by hand. Read the id back through the same API you are going to write with — one routes(filter: ...) query returns ids in the form the mutation expects. That is cheaper than the two failed inserts it takes to work it out, and it stays correct if the convention ever changes.
The route's primary key is a byte-swapped GUID — generate it yourself
Routes.Id is a RAW(16) holding a GUID in the byte order .NET writes: the first three groups are little-endian. A plain UUID inserted as-is is read back by the .NET side as a different GUID, and any child route then fails its foreign key with ORA-02291 — which reads like a missing parent rather than a byte-order problem.
pcwrite.dotnet_guid() produces one in the right order, and pcwrite binds RAW columns through HEXTORAW. So a route is one ordinary write with an id you supply.
⚠ Historically this was done through an API that inserted the row with every column correct and Id = 000…0. Nothing errored; the next route added the same way collided on the primary key, and no route could ever be a child of it. Writing the key yourself avoids that entirely — but if you meet a route with a zero id, that is where it came from.
The LT_* lookups have no gateway write — but SQL reaches them
The gateway command registry covers the IPAL configuration surface (PrBasicProducts*, PrBasicCovers*, PrPricingFactors*, SrMessages* …). It does not cover the lookup tables: LtCoverCodeGetList, LtVariablesGetList, LtObjectCodeGetList, LtRiskCodeGetList, LtEventCodeGetList, LtLdCodesGetList are read-only, and there is no create command for any.
They are nonetheless writable directly in SQL, and that is how a product introducing a new cover code, variable, risk or event code is completed. Verified by doing it. pcwrite maps them like any other table.
⚠ Do not conclude "no write path" from the absence of a gateway command. An earlier version of this file said these writes existed only on a separate service, and that a product needing a new lookup code could not be completed while that service was down. Both were wrong: the service was merely the path someone happened to use. When a gateway command is missing, check whether the table itself is writable before reporting a blocker.
⚠ Order still matters: LT_*.DESCR_LINK is a foreign key to the message table, so the label has to exist before the lookup row. And the FK from the configuration row to the lookup arrives late — the product row inserts happily and the cover fails — so create the codes first.
Idempotence guards that report success and do the wrong thing
pcwrite guards an insert on the primary key, else a unique constraint, else the NOT NULL columns being written. Four entities defeat that, and each failure is silent:
| | Entity | What goes wrong | Guard on instead
| PR_OPERATIONS | PRODUCT_CODE is nullable, so it is not in the NOT NULL fallback. A new product's operation row matches another product's identical operation and is skipped as "already present". | the NOT NULL set plus any nullable scoping column in the payload — PRODUCT_CODE, BASIC_PRODUCT_ID, VERSION_ID, COVER_ID, OBJECT_CODE, COVER_CODE
| any table with a DATE in the guard (PR_BASIC_LD.VALID_FROM) | the guard's binds must get the same conversion as the insert's, or ORA-01861. It does not fail on the first run — when no row survives the earlier predicates Oracle never evaluates the comparison — so it fails on the second, which is exactly the resumability case. | build the guard with the same placeholder/bind conversion as the insert
| PPA_PRODUCT_VERSIONS | sequence PK, no unique constraint, and VERSION_STAGE is NOT NULL and therefore in the fallback. Once the version is DEPLOYED, a payload saying OPEN no longer matches and a re-run inserts a second, empty version. | (PRODUCT_ID, TEMPLATE_CODE, EFFECTIVE_DATE), checked by hand
| SRD_SYS."Routes" | the GUID id is generated by the caller, so a re-run with a fresh state file mints a new id, the PK guard never matches, and a second tile appears with the same (ParentId, Path) — and duplicate leaf paths make route resolution open the wrong product. | (ParentId, Path), reusing the id already in the database
The first two are fixed in scripts/pcwrite.py. The last two cannot be — the guard cannot know an entity's identity when the payload does not carry it — so check existence yourself before creating a version or a route.
⚠ A duplicate created this way is usually disabled rather than deleted: a spurious route gets Disabled='Y', IsDashboardItem='N' and a distinct Path; a spurious version gets a DESCR saying what it is. That is a judgement about live products, not a limitation — DELETE is permitted on this instance's writable tables. Plan the guard before the write anyway: a duplicate you never created needs no decision.
Known gap
PR_ANNEX_TYPES has no create command on the gateway — pr_annex_types_getlist only. Annex types govern mid-term modifications rather than initial issuance, so a product can be built and priced without one, but it cannot be completed this way.
Source documents
2 items
The inputs for this product — the tariff and the product template — reproduced in full as extracted text.
ТАРИФА_КАСКО_МАКСИМУМ.pdfBase rates by clause and vehicle age, loadings, discounts and applicable taxes. 10 pages.1,122 lines
--- page 1 ---
ТАРИФА ПО ЗАСТРАХОВКА „КАСКО МАКСИМУМ“
в сила от 01.08.2026 г.
I. ТАРИФА ПО ЗАСТРАХОВКА „КАСКО МАКСИМУМ“ — БЕЗ САМОУЧАСТИЕ
Вид ППС
ВЪЗРАСТ НА МПС
КЛАУЗА
МПС до 2 години
вкл.
МПС над 2 до 4
години вкл.
МПС над 4 до 6
години вкл.
МПС над 6 до 9
години вкл.
МПС над 9 до 12
години вкл.
МПС над 12 години
Леки автомобили до 9 места
Автобуси над 9 до 16 места
БЕЗ САМОУЧАСТИЕ
Пожар и природни бедствия
1.17%
1.37%
1.56%
1.77%
1.95%
1.93%
Пожар, природни бедствия и ПТП
4.59%
5.39%
6.12%
6.94%
7.65%
7.61%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
4.84%
5.68%
6.45%
7.15%
8.07%
7.84%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
5.55%
6.37%
7.40%
8.21%
9.25%
8.99%
Пълно каско — … — с включен авансов бонус
4.08%
4.70%
5.47%
5.96%
6.74%
6.57%
Бонус каско
7.27%
8.35%
9.48%
10.75%
11.86%
11.78%
Товарни автомобили до 3.5 т общо тегло
Пикапи
БЕЗ САМОУЧАСТИЕ
Пожар и природни бедствия
1.42%
1.63%
1.89%
2.09%
2.36%
2.29%
Пожар, природни бедствия и ПТП
1.98%
2.27%
2.63%
2.92%
3.22%
3.20%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
2.10%
2.46%
2.80%
3.17%
3.50%
3.48%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
2.11%
2.47%
2.81%
3.18%
3.51%
3.49%
Пълно каско — … — с включен авансов бонус
1.55%
1.83%
2.04%
2.27%
2.56%
2.50%
Бонус каско
2.78%
3.19%
3.70%
4.10%
4.63%
4.50%
Автобуси над 16 места
Товарни автомобили над 3.5 т общо тегло
Прикачни устройства
БЕЗ САМОУЧАСТИЕ
Пожар и природни бедствия
1.05%
1.23%
1.39%
1.58%
1.74%
1.69%
Пожар, природни бедствия и ПТП
1.44%
1.65%
1.92%
2.13%
2.40%
2.33%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
1.58%
1.81%
2.10%
2.33%
2.63%
2.55%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
1.67%
1.92%
2.23%
2.47%
2.73%
2.71%
Пълно каско — … — с включен авансов бонус
1.21%
1.39%
1.59%
1.81%
2.00%
1.99%
Бонус каско
2.15%
2.52%
2.86%
3.24%
3.58%
3.56%
Селскостопански машини
Строителни машини
БЕЗ САМОУЧАСТИЕ
Пожар и природни бедствия
0.72%
0.82%
0.93%
1.06%
1.17%
1.16%
Пожар, природни бедствия и ПТП
0.82%
0.97%
1.10%
1.24%
1.37%
1.36%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
1.18%
1.38%
1.57%
1.78%
1.96%
1.90%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
1.42%
1.63%
1.89%
2.10%
2.37%
2.30%
Пълно каско — … — с включен авансов бонус
1.03%
1.19%
1.39%
1.54%
1.74%
1.70%
Бонус каско
1.85%
2.13%
2.47%
2.74%
3.02%
3.01%
Мотоциклети, мотопеди и ATV
БЕЗ САМОУЧАСТИЕ
Пожар и природни бедствия
1.66%
1.91%
2.22%
2.46%
2.77%
2.69%
Пожар, природни бедствия и ПТП
6.28%
7.21%
8.37%
9.28%
10.46%
10.17%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
6.68%
7.67%
8.70%
9.87%
10.88%
10.82%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
7.58%
8.90%
10.10%
11.45%
12.63%
12.55%
Пълно каско — … — с включен авансов бонус
5.52%
6.50%
7.40%
8.41%
9.30%
9.07%
Бонус каско
9.93%
11.40%
13.23%
14.68%
16.55%
16.09%
* ПРИ СКЛЮЧВАНЕ НА МНОГОГОДИШНА ПОЛИЦА ТАРИФНОТО ЧИСЛО СЕ ЗАПАЗВА ЗА ЦЕЛИЯ СРОК. Клаузите са взаимоизключващи се — по полицата се избира точно една. Самоучастието определя ПО КОЯ ТАРИФА се изчислява премията и не е отстъпка.
--- page 2 ---
VI. ЗАВИШЕНИЯ — ПРИЛАГАТ СЕ СУМАРНО ВЪРХУ ОСНОВНАТА ПРЕМИЯ
Завишение
Условие
Стойност
Завишение за възраст на собственика
1. собственик/лизингополучател до 26 години
12%
2. собственик/лизингополучател от 26 до 30 години
6%
Завишение за наличие на щети
1. при 2 щети с обща сума над прага по раздел „Праг на щетите“
8%
2. при 3 до 4 щети над същия праг
40%
3. при 5 до 6 щети над същия праг
80%
4. над 6 щети над същия праг
110%
При прилагането не се вземат под внимание регресните претенции.
Завишение за ползване
Използване на МПС за обучение
45%
Отдаване на МПС под наем или аренда
55%
Превоз на пътници срещу заплащане — регистрирани като таксиметрови
110%
Превоз на пътници срещу заплащане — всички останали
55%
Завишение за марка
Porsche
8%
Mercedes-Benz със застрахователна сума до 25 000 EUR
6.5%
Land Rover със застрахователна сума до 25 000 EUR
6.5%
Завишения за изключени рискове
Стачки, локаути или други подобни действия
22%
Ударна вълна от летателни средства със звукова или свръхзвукова скорост
12%
Участие в състезания, тренировки, изпитания и проби за издръжливост
Определя се от дирекция „Автомобилно
застраховане“
Разширено покритие за щети без документ
при сключване на полицата
25%
при действаща полица, с издаване на добавък
35%
VII. ОТСТЪПКИ — ПРИЛАГАТ СЕ СУМАРНО ВЪРХУ ОСНОВНАТА ПРЕМИЯ
Отстъпка
Условие
Стойност
Отстъпка за липса на щети в предходна година
I година
8%
II година
14%
III и следващи години
22%
Отстъпката не се прилага при ползването на тарифа с включен авансов бонус.
Еднократно плащане
премията се заплаща наведнъж
6%
Отстъпка за възраст
1. собственик/лизингополучател над 42 до 55 години
6%
2. собственик/лизингополучател над 55 години
11%
Отстъпката не се прилага при ползването на тарифа с включен авансов бонус.
Отстъпки за ползване на сервиз
1. ремонт в доверен сервиз с оригинални части, за ППС от група 1 с право на официален сервиз
12%
2. ремонт в доверен сервиз за ППС с право на официален сервиз — групи 1 и 2
16%
групи 3, 4 и 5
9%
3. обезщетение само по експертна оценка за ППС с право на доверен сервиз
16%
Прилагат се само ако ППС отговаря на условията за съответния сервиз и при достатъчна премия — след прилагане на отстъпките и завишенията. Не се
прилагат при тарифа с включен авансов бонус.
Нов клиент или ППС
за собственик или ППС без сключвана при нас застраховка Каско, или предходната е изтекла преди повече от 1 година. Не се прилага при тарифа с включен
авансов бонус.
6%
Комбиниран продукт
при сключена „Гражданска отговорност“ и/или „Имущество“ и/или „Злополука“. Не се прилага при тарифа с включен авансов бонус.
6%
--- page 3 ---
Отстъпка
Условие
Стойност
Зелено каско
за ППС от групи 1 и 2 с електрическо или хибридно задвижване
12%
Регионална отстъпка
за ППС с адрес на собственика/лизингополучателя съгласно СРМПС в области Видин, Враца, Кюстендил, Перник и Монтана, както и в общините Козлодуй,
Мездра и Ботевград
12%
за ППС с адрес в области Габрово, Ловеч, Разград, Силистра, Търговище и Ямбол
6%
Данък върху застрахователната премия — 2.5% върху премията след отстъпки и завишения.
--- page 4 ---
ТАРИФА ПО ЗАСТРАХОВКА „КАСКО МАКСИМУМ“
в сила от 01.08.2026 г.
I. ТАРИФА ПО ЗАСТРАХОВКА „КАСКО МАКСИМУМ“ — СЪС САМОУЧАСТИЕ 200 EUR
Вид ППС
ВЪЗРАСТ НА МПС
КЛАУЗА
МПС до 2 години
вкл.
МПС над 2 до 4
години вкл.
МПС над 4 до 6
години вкл.
МПС над 6 до 9
години вкл.
МПС над 9 до 12
години вкл.
МПС над 12 години
Леки автомобили до 9 места
Автобуси над 9 до 16 места
СЪС САМОУЧАСТИЕ 200 EUR
Пожар и природни бедствия
1.02%
1.19%
1.36%
1.54%
1.70%
1.69%
Пожар, природни бедствия и ПТП
4.00%
4.70%
5.33%
6.04%
6.67%
6.62%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
4.22%
4.95%
5.62%
6.23%
7.03%
6.83%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
4.83%
5.55%
6.44%
7.15%
8.06%
7.83%
Пълно каско — … — с включен авансов бонус
3.55%
4.09%
4.76%
5.19%
5.87%
5.72%
Бонус каско
6.33%
7.27%
8.26%
9.36%
10.33%
10.26%
Товарни автомобили до 3.5 т общо тегло
Пикапи
СЪС САМОУЧАСТИЕ 200 EUR
Пожар и природни бедствия
1.23%
1.42%
1.64%
1.82%
2.05%
2.00%
Пожар, природни бедствия и ПТП
1.72%
1.98%
2.29%
2.54%
2.81%
2.79%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
1.83%
2.15%
2.44%
2.76%
3.05%
3.03%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
1.84%
2.15%
2.45%
2.77%
3.06%
3.04%
Пълно каско — … — с включен авансов бонус
1.35%
1.59%
1.77%
1.97%
2.23%
2.18%
Бонус каско
2.42%
2.78%
3.22%
3.57%
4.03%
3.92%
Автобуси над 16 места
Товарни автомобили над 3.5 т общо тегло
Прикачни устройства
СЪС САМОУЧАСТИЕ 200 EUR
Пожар и природни бедствия
0.91%
1.07%
1.21%
1.38%
1.52%
1.48%
Пожар, природни бедствия и ПТП
1.25%
1.44%
1.67%
1.85%
2.09%
2.03%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
1.37%
1.58%
1.83%
2.03%
2.29%
2.22%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
1.46%
1.67%
1.94%
2.15%
2.38%
2.36%
Пълно каско — … — с включен авансов бонус
1.05%
1.21%
1.38%
1.57%
1.74%
1.73%
Бонус каско
1.87%
2.20%
2.49%
2.83%
3.12%
3.10%
Селскостопански машини
Строителни машини
СЪС САМОУЧАСТИЕ 200 EUR
Пожар и природни бедствия
0.62%
0.72%
0.81%
0.92%
1.02%
1.01%
Пожар, природни бедствия и ПТП
0.72%
0.84%
0.96%
1.08%
1.20%
1.19%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
1.02%
1.20%
1.36%
1.55%
1.71%
1.66%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
1.24%
1.42%
1.65%
1.83%
2.06%
2.00%
Пълно каско — … — с включен авансов бонус
0.90%
1.04%
1.21%
1.34%
1.52%
1.48%
Бонус каско
1.62%
1.86%
2.15%
2.39%
2.63%
2.62%
Мотоциклети, мотопеди и ATV
СЪС САМОУЧАСТИЕ 200 EUR
Пожар и природни бедствия
1.45%
1.66%
1.93%
2.14%
2.41%
2.35%
Пожар, природни бедствия и ПТП
5.47%
6.28%
7.29%
8.08%
9.11%
8.86%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
5.81%
6.68%
7.58%
8.60%
9.48%
9.42%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
6.60%
7.75%
8.80%
9.97%
11.00%
10.93%
Пълно каско — … — с включен авансов бонус
4.81%
5.66%
6.44%
7.32%
8.10%
7.90%
Бонус каско
8.65%
9.93%
11.53%
12.78%
14.41%
14.01%
* ПРИ СКЛЮЧВАНЕ НА МНОГОГОДИШНА ПОЛИЦА ТАРИФНОТО ЧИСЛО СЕ ЗАПАЗВА ЗА ЦЕЛИЯ СРОК. Клаузите са взаимоизключващи се — по полицата се избира точно една. Самоучастието определя ПО КОЯ ТАРИФА се изчислява премията и не е отстъпка.
--- page 5 ---
VI. ЗАВИШЕНИЯ — ПРИЛАГАТ СЕ СУМАРНО ВЪРХУ ОСНОВНАТА ПРЕМИЯ
Завишение
Условие
Стойност
Завишение за възраст на собственика
1. собственик/лизингополучател до 26 години
12%
2. собственик/лизингополучател от 26 до 30 години
6%
Завишение за наличие на щети
1. при 2 щети с обща сума над прага по раздел „Праг на щетите“
8%
2. при 3 до 4 щети над същия праг
40%
3. при 5 до 6 щети над същия праг
80%
4. над 6 щети над същия праг
110%
При прилагането не се вземат под внимание регресните претенции.
Завишение за ползване
Използване на МПС за обучение
45%
Отдаване на МПС под наем или аренда
55%
Превоз на пътници срещу заплащане — регистрирани като таксиметрови
110%
Превоз на пътници срещу заплащане — всички останали
55%
Завишение за марка
Porsche
8%
Mercedes-Benz със застрахователна сума до 25 000 EUR
6.5%
Land Rover със застрахователна сума до 25 000 EUR
6.5%
Завишения за изключени рискове
Стачки, локаути или други подобни действия
22%
Ударна вълна от летателни средства със звукова или свръхзвукова скорост
12%
Участие в състезания, тренировки, изпитания и проби за издръжливост
Определя се от дирекция „Автомобилно
застраховане“
Разширено покритие за щети без документ
при сключване на полицата
25%
при действаща полица, с издаване на добавък
35%
VII. ОТСТЪПКИ — ПРИЛАГАТ СЕ СУМАРНО ВЪРХУ ОСНОВНАТА ПРЕМИЯ
Отстъпка
Условие
Стойност
Отстъпка за липса на щети в предходна година
I година
8%
II година
14%
III и следващи години
22%
Отстъпката не се прилага при ползването на тарифа с включен авансов бонус.
Еднократно плащане
премията се заплаща наведнъж
6%
Отстъпка за възраст
1. собственик/лизингополучател над 42 до 55 години
6%
2. собственик/лизингополучател над 55 години
11%
Отстъпката не се прилага при ползването на тарифа с включен авансов бонус.
Отстъпки за ползване на сервиз
1. ремонт в доверен сервиз с оригинални части, за ППС от група 1 с право на официален сервиз
12%
2. ремонт в доверен сервиз за ППС с право на официален сервиз — групи 1 и 2
16%
групи 3, 4 и 5
9%
3. обезщетение само по експертна оценка за ППС с право на доверен сервиз
16%
Прилагат се само ако ППС отговаря на условията за съответния сервиз и при достатъчна премия — след прилагане на отстъпките и завишенията. Не се
прилагат при тарифа с включен авансов бонус.
Нов клиент или ППС
за собственик или ППС без сключвана при нас застраховка Каско, или предходната е изтекла преди повече от 1 година. Не се прилага при тарифа с включен
авансов бонус.
6%
Комбиниран продукт
при сключена „Гражданска отговорност“ и/или „Имущество“ и/или „Злополука“. Не се прилага при тарифа с включен авансов бонус.
6%
--- page 6 ---
Отстъпка
Условие
Стойност
Зелено каско
за ППС от групи 1 и 2 с електрическо или хибридно задвижване
12%
Регионална отстъпка
за ППС с адрес на собственика/лизингополучателя съгласно СРМПС в области Видин, Враца, Кюстендил, Перник и Монтана, както и в общините Козлодуй,
Мездра и Ботевград
12%
за ППС с адрес в области Габрово, Ловеч, Разград, Силистра, Търговище и Ямбол
6%
Данък върху застрахователната премия — 2.5% върху премията след отстъпки и завишения.
--- page 7 ---
ТАРИФА ПО ЗАСТРАХОВКА „КАСКО МАКСИМУМ“
в сила от 01.08.2026 г.
I. ТАРИФА ПО ЗАСТРАХОВКА „КАСКО МАКСИМУМ“ — СЪС САМОУЧАСТИЕ 400 EUR
Вид ППС
ВЪЗРАСТ НА МПС
КЛАУЗА
МПС до 2 години
вкл.
МПС над 2 до 4
години вкл.
МПС над 4 до 6
години вкл.
МПС над 6 до 9
години вкл.
МПС над 9 до 12
години вкл.
МПС над 12 години
Леки автомобили до 9 места
Автобуси над 9 до 16 места
СЪС САМОУЧАСТИЕ 400 EUR
Пожар и природни бедствия
0.93%
1.09%
1.24%
1.41%
1.55%
1.54%
Пожар, природни бедствия и ПТП
3.66%
4.30%
4.88%
5.54%
6.11%
6.07%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
3.86%
4.53%
5.15%
5.71%
6.44%
6.26%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
4.43%
5.09%
5.90%
6.55%
7.38%
7.18%
Пълно каско — … — с включен авансов бонус
3.26%
3.75%
4.36%
4.76%
5.38%
5.24%
Бонус каско
5.80%
6.66%
7.57%
8.58%
9.46%
9.40%
Товарни автомобили до 3.5 т общо тегло
Пикапи
СЪС САМОУЧАСТИЕ 400 EUR
Пожар и природни бедствия
1.13%
1.30%
1.51%
1.67%
1.88%
1.83%
Пожар, природни бедствия и ПТП
1.58%
1.81%
2.10%
2.33%
2.57%
2.55%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
1.67%
1.97%
2.23%
2.53%
2.79%
2.77%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
1.68%
1.97%
2.24%
2.54%
2.80%
2.79%
Пълно каско — … — с включен авансов бонус
1.24%
1.46%
1.63%
1.81%
2.05%
1.99%
Бонус каско
2.21%
2.54%
2.95%
3.27%
3.69%
3.59%
Автобуси над 16 места
Товарни автомобили над 3.5 т общо тегло
Прикачни устройства
СЪС САМОУЧАСТИЕ 400 EUR
Пожар и природни бедствия
0.83%
0.98%
1.11%
1.26%
1.39%
1.35%
Пожар, природни бедствия и ПТП
1.15%
1.32%
1.53%
1.70%
1.92%
1.86%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
1.26%
1.44%
1.68%
1.86%
2.10%
2.04%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
1.34%
1.53%
1.78%
1.97%
2.18%
2.16%
Пълно каско — … — с включен авансов бонус
0.96%
1.11%
1.27%
1.44%
1.59%
1.59%
Бонус каско
1.71%
2.01%
2.28%
2.59%
2.86%
2.84%
Селскостопански машини
Строителни машини
СЪС САМОУЧАСТИЕ 400 EUR
Пожар и природни бедствия
0.57%
0.66%
0.75%
0.85%
0.93%
0.93%
Пожар, природни бедствия и ПТП
0.66%
0.77%
0.88%
0.99%
1.10%
1.09%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
0.94%
1.10%
1.25%
1.42%
1.56%
1.52%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
1.13%
1.30%
1.51%
1.67%
1.89%
1.84%
Пълно каско — … — с включен авансов бонус
0.82%
0.95%
1.11%
1.23%
1.39%
1.36%
Бонус каско
1.48%
1.70%
1.97%
2.19%
2.41%
2.40%
Мотоциклети, мотопеди и ATV
СЪС САМОУЧАСТИЕ 400 EUR
Пожар и природни бедствия
1.33%
1.52%
1.77%
1.96%
2.21%
2.15%
Пожар, природни бедствия и ПТП
5.01%
5.75%
6.68%
7.40%
8.35%
8.12%
Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица
5.33%
6.12%
6.95%
7.88%
8.69%
8.63%
Пълно каско — пожар, природни бедствия, ПТП и злоумишлени действия
на трети лица, кражба и грабеж
6.05%
7.10%
8.06%
9.14%
10.08%
10.02%
Пълно каско — … — с включен авансов бонус
4.40%
5.18%
5.90%
6.71%
7.42%
7.24%
Бонус каско
7.92%
9.10%
10.56%
11.71%
13.21%
12.84%
* ПРИ СКЛЮЧВАНЕ НА МНОГОГОДИШНА ПОЛИЦА ТАРИФНОТО ЧИСЛО СЕ ЗАПАЗВА ЗА ЦЕЛИЯ СРОК. Клаузите са взаимоизключващи се — по полицата се избира точно една. Самоучастието определя ПО КОЯ ТАРИФА се изчислява премията и не е отстъпка.
--- page 8 ---
VI. ЗАВИШЕНИЯ — ПРИЛАГАТ СЕ СУМАРНО ВЪРХУ ОСНОВНАТА ПРЕМИЯ
Завишение
Условие
Стойност
Завишение за възраст на собственика
1. собственик/лизингополучател до 26 години
12%
2. собственик/лизингополучател от 26 до 30 години
6%
Завишение за наличие на щети
1. при 2 щети с обща сума над прага по раздел „Праг на щетите“
8%
2. при 3 до 4 щети над същия праг
40%
3. при 5 до 6 щети над същия праг
80%
4. над 6 щети над същия праг
110%
При прилагането не се вземат под внимание регресните претенции.
Завишение за ползване
Използване на МПС за обучение
45%
Отдаване на МПС под наем или аренда
55%
Превоз на пътници срещу заплащане — регистрирани като таксиметрови
110%
Превоз на пътници срещу заплащане — всички останали
55%
Завишение за марка
Porsche
8%
Mercedes-Benz със застрахователна сума до 25 000 EUR
6.5%
Land Rover със застрахователна сума до 25 000 EUR
6.5%
Завишения за изключени рискове
Стачки, локаути или други подобни действия
22%
Ударна вълна от летателни средства със звукова или свръхзвукова скорост
12%
Участие в състезания, тренировки, изпитания и проби за издръжливост
Определя се от дирекция „Автомобилно
застраховане“
Разширено покритие за щети без документ
при сключване на полицата
25%
при действаща полица, с издаване на добавък
35%
VII. ОТСТЪПКИ — ПРИЛАГАТ СЕ СУМАРНО ВЪРХУ ОСНОВНАТА ПРЕМИЯ
Отстъпка
Условие
Стойност
Отстъпка за липса на щети в предходна година
I година
8%
II година
14%
III и следващи години
22%
Отстъпката не се прилага при ползването на тарифа с включен авансов бонус.
Еднократно плащане
премията се заплаща наведнъж
6%
Отстъпка за възраст
1. собственик/лизингополучател над 42 до 55 години
6%
2. собственик/лизингополучател над 55 години
11%
Отстъпката не се прилага при ползването на тарифа с включен авансов бонус.
Отстъпки за ползване на сервиз
1. ремонт в доверен сервиз с оригинални части, за ППС от група 1 с право на официален сервиз
12%
2. ремонт в доверен сервиз за ППС с право на официален сервиз — групи 1 и 2
16%
групи 3, 4 и 5
9%
3. обезщетение само по експертна оценка за ППС с право на доверен сервиз
16%
Прилагат се само ако ППС отговаря на условията за съответния сервиз и при достатъчна премия — след прилагане на отстъпките и завишенията. Не се
прилагат при тарифа с включен авансов бонус.
Нов клиент или ППС
за собственик или ППС без сключвана при нас застраховка Каско, или предходната е изтекла преди повече от 1 година. Не се прилага при тарифа с включен
авансов бонус.
6%
Комбиниран продукт
при сключена „Гражданска отговорност“ и/или „Имущество“ и/или „Злополука“. Не се прилага при тарифа с включен авансов бонус.
6%
--- page 9 ---
Отстъпка
Условие
Стойност
Зелено каско
за ППС от групи 1 и 2 с електрическо или хибридно задвижване
12%
Регионална отстъпка
за ППС с адрес на собственика/лизингополучателя съгласно СРМПС в области Видин, Враца, Кюстендил, Перник и Монтана, както и в общините Козлодуй,
Мездра и Ботевград
12%
за ППС с адрес в области Габрово, Ловеч, Разград, Силистра, Търговище и Ямбол
6%
Данък върху застрахователната премия — 2.5% върху премията след отстъпки и завишения.
--- page 10 ---
II. „ПОМОЩ НА ПЪТЯ“
Покритие
КАРТА
МПС до 6 години
МПС над 6 до 12 години
МПС над 12 години
Асистанс при застрахователно събитие — Р България
серия Б
Без премия
Без премия
Без премия
Събитие при експлоатация и техническа повреда
серия С
16.00 EUR
19.00 EUR
23.00 EUR
Асистанс за чужбина, без ограничение
серия Д
26.00 EUR
30.00 EUR
36.00 EUR
„Без премия“ означава, че покритието се включва без допълнителна премия — не че премията липсва.
III. „МЕХАНИЧНА ПОВРЕДА“ — двигател, скоростна кутия и диференциал
МПС до 1800 куб. см
МПС над 1800 куб. см
58.00 EUR
86.00 EUR
IV. „ЗЛОПОЛУКА НА МЕСТАТА В МПС“
5.20 EUR за всяко място по регистрационен талон без мястото на водача: Max(МЕСТА − 1; 1). Застрахователна сума 6 000 EUR на място.
V. „ЗАМЕСТВАЩ АВТОМОБИЛ“
14% от базовата премия по избраната клауза от раздел I. Предлага се само към клаузи CM4, CM5 и CM6.
VIII. ЛЕГЕНДА — обвързване на тарифните раздели с покритията
Раздел
Покритие
Етикети в таблицата
I — по клаузи
CM_CASCO (основно каско)
„КЛАУЗА“ = CM1…CM6; „Вид ППС“ = тарифна група G1…G5; „ВЪЗРАСТ НА МПС“ = възрастова лента
II
CM_ASSIST
„КАРТА“ = серия Б / С / Д
III
CM_MECH
праг по обем на двигателя (куб. см)
IV
CM_SEATS
„МЕСТА“ = брой места по регистрационен талон
V
CM_REPLACE
базова премия по раздел I
Product_Template_КАСКО_МАКСИМУМ_9951.xlsxCovers, clauses, packages, cover dependencies, premium rules and the underwriting questionnaire, across 13 sheets.281 lines
=== SHEET: Промени_документ (5 rows x 4 cols) ===
Версия | Дата | Автор | Описание
1.0 | 2026-06-22 | Дирекция „Автомобилно застраховане“ | Нов продукт „Каско Максимум“
1.1 | 2026-07-10 | Актюерски отдел | Тарифа по клаузи; три нива на самоучастие
1.2 | 2026-07-24 | Дирекция „Автомобилно застраховане“ | Добавен раздел „Оферти“ и обвързване клауза→шаблон
1.3 | 2026-07-29 | Актюерски отдел | Уточнени изключения при тарифа с включен авансов бонус
=== SHEET: Продукт (15 rows x 3 cols) ===
Параметър | Стойност | Коментар
Код продукт | 9951 | Да се потвърди, че кодът е свободен
Наименование | CascoMax
Наименование на местен език | Каско Максимум
Вид застраховка | Автомобилно застраховане
Тип продукт | Каско
Застрахован обект | CAR | Съществуващ обектен код
Тип плащане | 3-Еднократно и на вноски
Валута | EUR
Държава | BG
Тип клиент | Физическо и юридическо лице
Минимален срок | 1 година
Максимален срок | 3 години | При многогодишна полица тарифното число се запазва
Тарифа | ТАРИФА_КАСКО_МАКСИМУМ.pdf | Раздели I–VIII
В сила от | 01.08.2026 | Съгласно тарифата
=== SHEET: Тарифни групи (12 rows x 4 cols) ===
Тарифна група | Вид ППС | Код на вида | Коментар
G1 | Леки автомобили до 9 места | PASSENGER
G1 | Автобуси над 9 до 16 места | MINIBUS_9_16
G2 | Товарни автомобили до 3.5 т общо тегло | TRUCK_UP_35
G2 | Пикапи | PICKUP
G3 | Автобуси над 16 места | BUS_OVER_16
G3 | Товарни автомобили над 3.5 т общо тегло | TRUCK_OVER_35
G3 | Прикачни устройства | TRAILER
G4 | Селскостопански машини | AGRI_MACHINE
G4 | Строителни машини | CONSTR_MACHINE
G5 | Мотоциклети и мотопеди | MOTORCYCLE
G5 | ATV | ATV
=== SHEET: Клаузи (10 rows x 5 cols) ===
Код клауза | Наименование | Включени покрития | С включен авансов бонус | Коментар
CM1 | Пожар и природни бедствия | CM_FIRE_NAT | Не
CM2 | Пожар, природни бедствия и ПТП | CM_FIRE_NAT, CM_ACCIDENT | Не
CM3 | Пожар, природни бедствия, ПТП и злоумишлени действия на трети лица | CM_FIRE_NAT, CM_ACCIDENT, CM_MALICIOUS | Не
CM4 | Пълно каско | CM_FIRE_NAT, CM_ACCIDENT, CM_MALICIOUS, CM_THEFT | Не
CM5 | Пълно каско — с включен авансов бонус | CM_FIRE_NAT, CM_ACCIDENT, CM_MALICIOUS, CM_THEFT | Да | Бонусът е включен в тарифното число
CM6 | Бонус каско | CM_FIRE_NAT, CM_ACCIDENT, CM_MALICIOUS, CM_THEFT | Не
Клаузите са ВЗАИМОИЗКЛЮЧВАЩИ СЕ — по една полица се избира точно една клауза.
При клауза с включен авансов бонус не се прилагат отстъпките, изрично посочени в раздел VII.
=== SHEET: Покрития (11 rows x 9 cols) ===
ПОКРИТИЯ | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
Код покритие | CM_FIRE_NAT | CM_ACCIDENT | CM_MALICIOUS | CM_THEFT | CM_ASSIST | CM_MECH | CM_SEATS | CM_REPLACE
Тип покритие | Задължително | Опционално | Опционално | Опционално | Опционално | Опционално | Опционално | Опционално
Име на покритие | Fire and natural disasters | Road traffic accident | Malicious acts of third parties | Theft and robbery | Roadside assistance | Mechanical breakdown | Passenger accident | Replacement vehicle
Име на покритие на местен език | Пожар и природни бедствия | Пътнотранспортно произшествие | Злоумишлени действия на трети лица | Кражба и грабеж | Помощ на пътя | Механична повреда | Злополука на местата в МПС | Заместващ автомобил
Застрахователна сума/Лимит | Избраната ЗС на МПС | Равна на ЗС по CM_FIRE_NAT | Равна на ЗС по CM_FIRE_NAT | Равна на ЗС по CM_FIRE_NAT | Съгласно условията | Съгласно условията | 6 000 EUR на място | 35 EUR на ден, до 20 дни
Изчисление на застрахователна сума | Ръчно въвеждане | Изчислява се | Изчислява се | Изчислява се | Фиксирано | Фиксирано | Фиксирано | Фиксирано
Тип изчисление на премия | Тарифа | Тарифа | Тарифа | Тарифа | Тарифа | Тарифа | Тарифа | Тарифа
Забележка | Част от всяка клауза | От клауза CM2 нагоре | От клауза CM3 нагоре | От клауза CM4 нагоре | Раздел II — по серия на картата | Раздел III | Раздел IV | Раздел V — само към клаузи CM4–CM6
=== SHEET: Зависимости между покрития (8 rows x 4 cols) ===
Покритие | Зависи от / Условие | Тип | Коментар
CM_ACCIDENT | CM_FIRE_NAT | Изисква | Клаузите са кумулативни
CM_MALICIOUS | CM_ACCIDENT | Изисква | Клаузите са кумулативни
CM_THEFT | CM_MALICIOUS | Изисква | Клаузите са кумулативни
CM_REPLACE | CM_THEFT | Изисква | Само към клаузи CM4–CM6
CM_MECH | Тарифна група в (G1, G2) | Условие
CM_SEATS | Тарифна група в (G1, G2, G3) | Условие
CM_ASSIST | Тарифна група в (G1, G2) | Условие | Раздел II не покрива машини и мотоциклети
=== SHEET: Оферти (6 rows x 7 cols) ===
Код оферта | Наименование | Клауза | Самоучастие | Тарифен шаблон | Допълнителни покрития | Коментар
9951_MINI | Каско Максимум Мини | CM1 | 400 EUR | CM_D400 | — | Само пожар и природни бедствия
9951_BASE | Каско Максимум Базов | CM3 | 200 EUR | CM_D200 | CM_ASSIST (серия С)
9951_FULL | Каско Максимум Пълен | CM4 | 0 | CM_D000 | CM_ASSIST (серия Д), CM_MECH, CM_SEATS, CM_REPLACE
9951_FULL_ADV | Каско Максимум Пълен с авансов бонус | CM5 | 0 | CM_D000_ADV | CM_ASSIST (серия Д), CM_MECH, CM_SEATS, CM_REPLACE
9951_BONUS | Каско Максимум Бонус | CM6 | 0 | CM_D000 | CM_ASSIST (серия Д), CM_SEATS, CM_REPLACE
=== SHEET: Правила за премия (9 rows x 5 cols) ===
Покритие | Формула нетна премия | Основа | Формула бруто премия | Референция
CM_FIRE_NAT / CM_ACCIDENT / CM_MALICIOUS / CM_THEFT | ЗС × тарифно число по избраната клауза | Застрахователна сума на МПС | нетна премия + данък | Раздел I
CM_ASSIST | Фиксирана премия по серия на картата и възрастова лента | — | нетна премия + данък | Раздел II
CM_MECH | Фиксирана премия по обем на двигателя | — | нетна премия + данък | Раздел III
CM_SEATS | 5.20 EUR × Max(МЕСТА − 1; 1) | Брой места по регистрационен талон | нетна премия + данък | Раздел IV
CM_REPLACE | 14% от базовата премия по избраната клауза | Базова премия по раздел I | нетна премия + данък | Раздел V
Отстъпките и завишенията се прилагат СУМАРНО върху основната премия (раздели VI и VII).
Данъкът от 2.5% се начислява върху премията след отстъпки и завишения.
=== SHEET: ЗС, Премийни лимити (9 rows x 6 cols) ===
Покритие | Валута | Тип | Мин | Макс | Коментар
CM_FIRE_NAT | EUR | ЗС | 2000 | 500000 | По избор на клиента
CM_ACCIDENT | EUR | ЗС | 2000 | 500000
CM_MALICIOUS | EUR | ЗС | 2000 | 500000
CM_THEFT | EUR | ЗС | 2000 | 500000
CM_ASSIST | EUR | ЗС | 0 | 0 | Съгласно условията
CM_MECH | EUR | ЗС | 0 | 0 | Съгласно условията
CM_SEATS | EUR | ЗС | 6000 | 6000 | На място
CM_REPLACE | EUR | ЗС | 700 | 700 | 35 EUR × 20 дни
=== SHEET: Подписвачески въпросник (91 rows x 8 cols) ===
Код въпрос | Текст въпрос | Тип отговор | Код отговор | Текст отговор | Дефолтна стойност | Код на свързани въпроси | Условия
Q1 | Вид на превозното средство | LOV | PASSENGER | Лек автомобил до 9 места | Да | Q2 | Определя тарифната група
| | | MINIBUS_9_16 | Автобус над 9 до 16 места | Не
| | | TRUCK_UP_35 | Товарен до 3.5 т | Не
| | | PICKUP | Пикап | Не
| | | BUS_OVER_16 | Автобус над 16 места | Не
| | | TRUCK_OVER_35 | Товарен над 3.5 т | Не
| | | TRAILER | Прикачно устройство | Не
| | | AGRI_MACHINE | Селскостопанска машина | Не
| | | CONSTR_MACHINE | Строителна машина | Не
| | | MOTORCYCLE | Мотоциклет или мотопед | Не
| | | ATV | ATV | Не
Q2 | Клауза | LOV | CM1 | Пожар и природни бедствия | Не | Q1 | Взаимоизключващи се
| | | CM2 | Пожар, природни бедствия и ПТП | Не
| | | CM3 | Пожар, природни бедствия, ПТП и злоумишлени действия на… | Не
| | | CM4 | Пълно каско | Да
| | | CM5 | Пълно каско — с включен авансов бонус | Не
| | | CM6 | Бонус каско | Не
Q3 | Възраст на МПС | LOV | UP_TO_2 | до 2 години вкл. | Не | | Определя колоната в раздел I
| | | A2_4 | над 2 до 4 години вкл. | Да
| | | A4_6 | над 4 до 6 години вкл. | Не
| | | A6_9 | над 6 до 9 години вкл. | Не
| | | A9_12 | над 9 до 12 години вкл. | Не
| | | OVER_12 | над 12 години | Не
Q4 | Самоучастие | LOV | 0 | Без самоучастие | Да
| | | 200 | 200 EUR | Не
| | | 400 | 400 EUR | Не
Q5 | Застрахователна сума на МПС | Number | | 30000 | Да | | Основа за раздел I
Q6 | Обем на двигателя (куб. см) | Number | | 1600 | Да | | Праг 1800 куб. см за раздел III
Q7 | Брой места по регистрационен талон | Number | | 5 | Да | | Основа за раздел IV
Q8 | Марка на МПС | LOV | PORSCHE | Porsche | Не | | Завишение за марка
| | | MERCEDES | Mercedes-Benz | Не
| | | LANDROVER | Land Rover | Не
| | | OTHER | Друга | Да
Q9 | Възраст на собственика/лизингополучателя | LOV | U26 | до 26 години | Не
| | | A26_30 | от 26 до 30 години | Не
| | | A30_42 | от 30 до 42 години | Да
| | | A42_55 | над 42 до 55 години | Не
| | | O55 | над 55 години | Не
Q10 | Вид употреба | LOV | NORMAL | Обичайна | Да
| | | TRAINING | Обучение | Не
| | | RENTAL | Наем или аренда | Не
| | | TAXI_REG | Таксиметрова — регистрирано | Не
| | | TAXI_OTHER | Превоз на пътници срещу заплащане — друго | Не
Q11 | Брой изплатени щети в предходната година | LOV | 0 | Няма | Да | Q12 | Завишение — само за щети над прага по раздел „Праг на щетите“
| | | 1 | 1 щета | Не
| | | 2 | 2 щети | Не
| | | 3_4 | 3 до 4 щети | Не
| | | 5_6 | 5 до 6 щети | Не
| | | OVER_6 | над 6 щети | Не
Q12 | Поредна година без щети | LOV | NONE | Има щети | Не | Q11 | Отстъпка за липса на щети
| | | Y1 | I година | Да
| | | Y2 | II година | Не
| | | Y3 | III и следващи | Не
Q13 | Начин на плащане | LOV | ONCE | Еднократно | Да
| | | INSTALMENTS | На вноски | Не
Q14 | Ползване на сервиз | LOV | NONE | Без опция | Да
| | | TRUSTED_ORIG | Доверен сервиз с оригинални части | Не
| | | TRUSTED | Доверен сервиз | Не
| | | EXPERT | Само по експертна оценка | Не
Q15 | Задвижване | LOV | ICE | Двигател с вътрешно горене | Да | | Зелено каско — групи 1 и 2
| | | ELECTRIC | Електрическо | Не
| | | HYBRID | Хибридно | Не
Q16 | Област по СРМПС | LOV | VID | Видин | Не | | Регионална отстъпка — 12%, 6% или без
| | | VRC | Враца | Не
| | | KNL | Кюстендил | Не
| | | PER | Перник | Не
| | | MON | Монтана | Не
| | | GAB | Габрово | Не
| | | LOV | Ловеч | Не
| | | RAZ | Разград | Не
| | | SLS | Силистра | Не
| | | TGV | Търговище | Не
| | | JAM | Ямбол | Не
| | | SOF | София | Да
| | | OTHER | Друга | Не
Q17 | Има ли сключена друга наша застраховка | LOV | Y | Да | Не | | Комбиниран продукт
| | | N | Не | Да
Q18 | Нов клиент или ППС | LOV | Y | Да | Не
| | | N | Не | Да
Q19 | Серия на картата „Помощ на пътя“ | LOV | NONE | Без покритие | Да | | Раздел II
| | | B | серия Б | Не
| | | C | серия С | Не
| | | D | серия Д | Не
Q20 | Разширено покритие за щети без документ | LOV | NO | Не | Да | | Завишение 25% / 35%
| | | AT_START | При сключване | Не
| | | MID_TERM | При действаща полица | Не
Q21 | Покриване на изключени рискове | LOV | NONE | Не | Да | | Раздел VI
| | | STRIKE | Стачки и локаути | Не
| | | SONIC | Ударна вълна | Не
| | | RACE | Състезания и изпитания | Не
=== SHEET: Зависимости между въпроси (65 rows x 5 cols) ===
Основен въпрос | Стойност | Зависим въпрос | Допустима стойност | Коментар
Q1 | PASSENGER | Q2 | CM1
Q1 | PASSENGER | Q2 | CM2
Q1 | PASSENGER | Q2 | CM3
Q1 | PASSENGER | Q2 | CM4
Q1 | PASSENGER | Q2 | CM5
Q1 | PASSENGER | Q2 | CM6
Q1 | MINIBUS_9_16 | Q2 | CM1
Q1 | MINIBUS_9_16 | Q2 | CM2
Q1 | MINIBUS_9_16 | Q2 | CM3
Q1 | MINIBUS_9_16 | Q2 | CM4
Q1 | MINIBUS_9_16 | Q2 | CM5
Q1 | MINIBUS_9_16 | Q2 | CM6
Q1 | TRUCK_UP_35 | Q2 | CM1
Q1 | TRUCK_UP_35 | Q2 | CM2
Q1 | TRUCK_UP_35 | Q2 | CM3
Q1 | TRUCK_UP_35 | Q2 | CM4
Q1 | TRUCK_UP_35 | Q2 | CM5
Q1 | TRUCK_UP_35 | Q2 | CM6
Q1 | PICKUP | Q2 | CM1
Q1 | PICKUP | Q2 | CM2
Q1 | PICKUP | Q2 | CM3
Q1 | PICKUP | Q2 | CM4
Q1 | PICKUP | Q2 | CM5
Q1 | PICKUP | Q2 | CM6
Q1 | BUS_OVER_16 | Q2 | CM1
Q1 | BUS_OVER_16 | Q2 | CM2
Q1 | BUS_OVER_16 | Q2 | CM3
Q1 | BUS_OVER_16 | Q2 | CM4
Q1 | TRUCK_OVER_35 | Q2 | CM1
Q1 | TRUCK_OVER_35 | Q2 | CM2
Q1 | TRUCK_OVER_35 | Q2 | CM3
Q1 | TRUCK_OVER_35 | Q2 | CM4
Q1 | TRAILER | Q2 | CM1
Q1 | TRAILER | Q2 | CM2
Q1 | TRAILER | Q2 | CM3
Q1 | TRAILER | Q2 | CM4
Q1 | AGRI_MACHINE | Q2 | CM1
Q1 | AGRI_MACHINE | Q2 | CM2
Q1 | AGRI_MACHINE | Q2 | CM3
Q1 | AGRI_MACHINE | Q2 | CM4
Q1 | CONSTR_MACHINE | Q2 | CM1
Q1 | CONSTR_MACHINE | Q2 | CM2
Q1 | CONSTR_MACHINE | Q2 | CM3
Q1 | CONSTR_MACHINE | Q2 | CM4
Q1 | MOTORCYCLE | Q2 | CM1
Q1 | MOTORCYCLE | Q2 | CM2
Q1 | MOTORCYCLE | Q2 | CM3
Q1 | MOTORCYCLE | Q2 | CM4
Q1 | ATV | Q2 | CM1
Q1 | ATV | Q2 | CM2
Q1 | ATV | Q2 | CM3
Q1 | ATV | Q2 | CM4
Q11 | 0 | Q12 | Y1
Q11 | 0 | Q12 | Y2
Q11 | 0 | Q12 | Y3
Q11 | 1 | Q12 | NONE
Q11 | 2 | Q12 | NONE
Q11 | 3_4 | Q12 | NONE
Q11 | 5_6 | Q12 | NONE
Q11 | OVER_6 | Q12 | NONE
Забележка: комбинации, които не фигурират по-горе, са недопустими.
Клаузите с включен авансов бонус (CM5) и „Бонус каско“ (CM6) се предлагат само за групи G1 и G2.
=== SHEET: Такси и приложими налози (2 rows x 9 cols) ===
# | Тип | Наименование | Автоматично/Ръчно | Приложимо ниво | Приложима основа | Допълнителна опция | Стойност | Приложимо условие
1 | Tax | Данък върху застрахователната премия | Автоматично | Полица | Премия | % | 2.5 | Върху премията след прилагане на отстъпките и завишенията
=== SHEET: Ref (24 rows x 2 cols) ===
Payment_Type
| 1-Еднократно
| 2-На вноски
| 3-Еднократно и на вноски
Insurance_Type
| Автомобилно застраховане
Product_Type
| Каско
Answer_Type
| Number
| Date
| Text
| LOV
Discount_Type
| Отстъпка
| Завишаване
Charge_Type
| Tax
| Fee
Generated from the workspace on 31 August 2026, 10:42.
three domains configured · one reported on
20 knowledge documents · 3 procedures · 8 rules · 2 sub-agents · 3 consulted agents