Write envelopes — the conventions a write path must obey
Gating decides whether a write happens and under whose approval. This page is the other half: what the write must look like to land. Every rule here cost at least one failed attempt, and none of the error messages names the actual problem — which is the reason the page exists. It is what the platform's write_executor has to encode for the Serdica estate.
Provenance and status. From the PC 9951 run on the Ablera dev database and its gateway, 31.08.2026 (Product Configurator § SRC, full text in the extract). Not re-measured on the customer estate; the running system outranks this page.
1. Three write paths, not one
P The rule, stated once (D74, D120, D136) — "write-path precedence" is a per-object map, not a ranking. A configuration write goes through the system's own configuration endpoint wherever the coverage map declares one, and otherwise through an approved statement template with parameters; the agent never authors a statement that reaches a database — it supplies the parameters, the executor renders the template and applies it, and the same template emits the revert (D120, Components — Configuration § 5). An endpoint is preferred where it exists because it applies the system's own validation and leaves its own trail; statements cannot be given up because they reach what no endpoint exposes. Three paths exist: (a) the system's own configuration endpoint, (b) a guarded SQL statement through the write executor under an approved shape (Gating § 4), (c) the gateway publication sequence for rate files (saveLdFile → saveLdRating → genRuleTable → ppaProductVersionsUpdate, Rating Files § 2). Which one an object takes is declared per configuration object in configure.endpoint_coverage (Whitelabel Catalogue § 1) and mirrored in the skill manifest's coverage_map (Contracts § 2); the executor refuses an object that has no entry. For Bulstrad today: rate-grid compile and cache reset → (a); rate-file publication → (c); every row object — products, covers, factors, values, messages, routes, the LT_* lookups (§ 7) and PR_ANNEX_TYPES (§ 9), which have no endpoint at all — → (b). WRITE_LOG.write_path ∈ {endpoint, statement, gateway, ui_action, send} records which path actually ran, so an audit never has to infer it from the statement text.
F Why the Bulstrad coverage lands where it does — this is the coverage, not the rule. Correcting a row object is the same statement path with an …Update, not a different one. Where a row endpoint does exist on this estate it is a thin renaming layer over the same tables, so it buys the conventions of § 2 and § 3 and nothing beyond them — which is why the map sends rows to (b) here and would not have to on another estate. A different estate declares a different map, and the rule above does not change.
F ⚠ Correcting in place is a working posture, not a database limitation — DELETE is permitted on the writable tables. The reason to prefer an update on a live product is that a delete is unreviewable and can break a policy already referencing the row. Tearing down a test product is the case where deleting is right. P So the derived teardown of Failure and Recovery § 2 is a delete set for a case's own new rows and an inverse-update set for anything it changed — never one mechanism for both.
2. The C# convention: every primary key is id
F The DTO input models expose the key as id, renamed onto the real column by AutoMapper. This is uniform across the whole DTO layer. Send the column name instead and the serialiser silently ignores it, the DTO's Id stays null, and the failure is:
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. One documented exception: the message-language input model carries a real MsgId foreign key plus a long Id.
3. The Analytics convention: send every column
F The Analytics handlers index columns straight out of the request, so a missing key is a KeyError surfaced as Internal error: 'companyId' — one key at a time, which makes fixing them individually a long loop.
P Read the source row, camelCase every column, send the whole thing — nulls and audit columns included — then override what should differ. Two or three iterations become one.
F The compile call takes {"content": {…params…}, "storage": null} and that is the whole body: storage is a sibling of content, not inside it. A payload that already carries a content key is passed through unchanged, so wrapping it a second time is rejected with 500 Internal error: 'storage' — a bare KeyError naming the key it could not find at the level it looked.
4. Gateway mechanics
F Two things that cost time and are not guessable: endpoint routes live under the gateway path, and getting the base wrong returns 405 from nginx, which reads as "this endpoint does not exist" and is not; and every command's payload is wrapped in content — without that wrapper the answer is 400 "Request has no content!" whatever else is sent.
F Read the failure mode, not the message:
| What comes back | What it means |
|---|---|
450 with a message |
the handler ran and the 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, 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 wrapper |
F ⚠ The observed malformed-input timeouts occurred before handler dispatch. That observation does not classify every later timeout. The connector must prove this failure phase from its operation contract/evidence; otherwise the outcome is unknown and reconciliation precedes any retry.
5. Order, and re-parenting onto returned ids
F Writes go in dependency order carrying a source → target id map: the product returns its id, covers return theirs, and the children of each cover re-parent onto what the platform returned, never onto what was predicted. Any real clone is hundreds of steps and will fail partway; continuing past a failure manufactures orphans — a cover that failed leaves its definitions with nothing to attach to. Completed steps and their returned ids go to a state file; the run stops at the first failure and resumes by skipping what is done. This is P-9 of Product Configurator § P, stated as the write path obeys it.
6. Routes ids are .NET byte-swapped GUIDs
F SRD_SYS."Routes" has a RAW(16) primary key holding a GUID in the byte order .NET writes — the first three groups are little-endian. Read as hex from Oracle it is one string; read back through the .NET API it is another:
Oracle hex f1a160f2 64fc 435b 8a95 5b1f06c5c9ec
App GUID f260a1f1-fc64-5b43-8a95-5b1f06c5c9ec
^^^^^^^^ ^^^^ ^^^^ each of the first three groups is byte-reversed
Passing the hex form as a parentId gives ORA-02291 … parent key not found, which reads like a missing parent and is a byte-order problem.
P Do not convert by hand: read the id back through the same API the write will use, which stays correct if the convention changes. Where a route id must be minted, generate it in the right order and bind the RAW column through HEXTORAW.
F ⚠ Historically routes were inserted with every column correct and Id = 000…0. Nothing errored; the next route collided on the primary key, and no route could ever be a child of it. A route with a zero id in the estate came from there.
7. The LT_* lookups: no gateway command, but writable
F The gateway registry covers the IPAL configuration surface and not the lookup tables — LtCoverCode, LtObjectCode, LtRiskCode, LtEventCode, LtLdCodes, LtVariables are read-only there, with no create command for any. They are nonetheless writable directly in SQL, and that is how a product introducing a new cover, object, risk or event code is completed. Verified by doing it.
F ⚠ Do not conclude "no write path" from the absence of a gateway command. An earlier version of this knowledge held that 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.
P So a missing command is not proof of missing write capability. Read catalogues/source and the configured identity grants first. If an authorised zero-row DML probe is still required, classify and gate it as a write: triggers/audit can observe the attempt. A read grant never authorises the probe.
F Order still matters: LT_*.DESCR_LINK is a foreign key to the message table, so the label exists before the lookup row (Translations); and the foreign key from the configuration row to the lookup arrives late — the product row inserts happily and the cover fails — so the codes are created first (Shared Vocabulary).
8. The four idempotence guards that report success and do the wrong thing
P-16 of Product Configurator § P records that there are four documented silent failure modes. F These are they. A guard normally tests 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 row and is skipped as "already present" |
the NOT NULL set plus every 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 — and it does not fail on the first run, because with no surviving row Oracle never evaluates the comparison. It fails on the second, which is exactly the resumability case |
build the guard with the same bind conversion as the insert |
PPA_PRODUCT_VERSIONS |
sequence key, 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 explicitly |
SRD_SYS."Routes" |
the id is minted by the caller, so a re-run with a fresh state file mints a new one, the key 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 |
P The first two are fixable in the writer. The last two are not — a guard cannot know an entity's identity when the payload does not carry it — so existence is checked before creating a version or a route, and the check is a plan step with its own assertion, not an implementation detail. The write auditor's boundedness check (Gating § 6 names exactly four — exactness, boundedness, reversibility, scope) therefore asks which of the four shapes a write is, and refuses a version or route create with no preceding existence assertion; the executor's own idempotence guard is what the assertion protects.
F A duplicate created this way is usually disabled rather than deleted on a live product — a spurious route gets a distinct path and is disabled, a spurious version gets a description saying what it is. That is a judgement about live products, not a limitation.
9. Known gap
F PR_ANNEX_TYPES has no create command on the gateway — a get-list 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 through the gateway path. Per § 7 the table itself is the place to test, not the registry. S3 owns the write; no gateway path exists — statement path under the approved shape (D74).
Related: Gating · Rating Files · Shared Vocabulary · Schema Quirks · Failure and Recovery