# External API contract

> > Status: largely met. This is the contract every CBX endpoint satisfies — admin CRUD, > authentication, and the end-user configurator — so that the product …

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/api_contract. Last updated 2026-08-12.

---
> **Status: largely met.** This is the contract every CBX endpoint satisfies — admin CRUD,
> authentication, and the end-user configurator — so that the product can be driven entirely from
> outside: by an AI assistant over MCP, by a headless storefront, by the admin UI itself, and by
> the E2E suite.
>
> **What remains** is the tail §8's ledger names: the **checkout tasks** (which still render
> pages), **`getMissingSelections*`** (left as the browser's bare array — `getOpenQuestions` is
> the API read), and the deprecated JS-off **link-task fallbacks** in the cart. Everything else
> answers in the two shapes of §2; the eight legacy shapes §1 records are what it migrated *from*.
> §8 is the ledger.
>
> The E2E suite pins both sides: `specs/api/*.characterization.spec.ts` records what exists now so it
> can be changed safely, `specs/api/*.contract.spec.ts` asserts this document and is `fixme`-marked
> until each endpoint complies, and `specs/backend/delete-task.spec.ts` covers the migrated one.

All paths are relative to the component root (`components/com_configbox/`).

---

## 1. Why — the eight shapes

Measured, with call sites. Every one of these is a JSON-ish response from a CBX endpoint today:

| # | Shape | Where |
|---|---|---|
| 1 | `{success, feedback, errors[], validationIssues[], …customData merged at top level}` | `ConfigboxJsonResponse` — `delete`, `copy`, `storeOrdering`, `addConfigurationToCart`, `getCalculationResult` |
| 2 | `{success, errors[], messages[], wasInsert, data, redirectUrl}` | `KenedoController::store()` — hand-built |
| 3 | `{success, errors[]}` | `KenedoController::ajaxDelete()`, `ajaxPublish()` — hand-built, no feedback |
| 4 | bare array, no envelope | `configuratorpage::getMissingSelectionsProduct` / `…Page` |
| 5 | bespoke engine payload with DOM instructions | `configuratorpage::makeSelection` |
| 6 | ~~`{success, errorMessage}`~~ gone — envelope / 401 problem since 2026-07-30, `logoutUser` followed 2026-07-31 | `user::loginUser`, `user::logoutUser` |
| 7 | ~~`{success, error}` (singular)~~ gone — on the envelope since 2026-07-31 | `controllers/testautomation.php` |
| 8 | HTTP redirect to an HTML page, no body — `addProductToCart` and `setCartPositionQuantity` answer the envelope now; `remove/edit/copyCartPosition` remain as deprecated JS-off fallbacks; `checkout::*` is the remaining tail | `cart::*` link tasks, `checkout::*` |

And one endpoint can be several of them at once: `KenedoController::delete()` answers with **JSON**,
or a **rendered HTML list** (`show_list=1`), or a **redirect to `HTTP_REFERER`** (`quickedit=1`),
depending on request parameters. A client cannot know which it will get without knowing the flags.

That is the whole problem: there is no shape a caller can rely on, so every consumer — the admin JS,
the storefront JS, the MCP server, the test suite — hand-codes its own parsing per endpoint.

---

## 2. The two shapes, and the status that picks between them

**The HTTP status carries the outcome. The body carries the detail.** There is deliberately no
`success` field.

A second copy of the outcome is a bug waiting to happen — the day a `200` carries `success:false`,
which one is right? And practically: load balancers, APM, log aggregators, retry middleware and MCP
clients all understand status codes and none of them understands `success`. An API that answers `200`
to everything reports a 0% error rate while customers cannot check out.

**Success — `2xx`, `Content-Type: application/json`**

```json
{
  "data": { "ids": [42], "deleted": 1 },
  "meta": { "feedback": "Records deleted." }
}
```

| Key | Meaning |
|---|---|
| `data` | The payload. Always an **object** — never a bare array, never a scalar, so paging or an extra field never changes the shape. |
| `meta` | Optional. About the *response* rather than the resource: paging counts, a translated `feedback` sentence, a deprecation notice. Never the outcome. |

> **The one exception: standalone documents.** `GET /cb-api/v1/openapi.json` and
> `GET /cb-api/v1/schemas/{kind}/{entity}.json` answer **bare** — the document at the root, no
> envelope, the schemas under their own `application/schema+json`. Their consumers are other
> people's tools (an OpenAPI importer, a `$ref`-dereferencing validator) which know the document's
> own root-level contract and not ours. Failures on those endpoints are problem+json like
> everywhere else. Nothing else qualifies: a payload read by clients of THIS API gets the envelope.

**Failure — `4xx`/`5xx`, `Content-Type: application/problem+json`**

[RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457) (which obsoleted RFC 7807), plus
two extension members it explicitly permits:

```json
{
  "type": "https://www.configbox.at/api/errors/record-in-use",
  "title": "Conflict",
  "status": 409,
  "code": "RECORD_IN_USE",
  "detail": "Could not delete product list, because it contains products.",
  "errors": [ { "code": "RECORD_IN_USE", "message": "…", "meta": { } } ],
  "validationIssues": [ { "field": "title_en_GB", "code": "REQUIRED", "message": "…" } ]
}
```

| Key | Meaning |
|---|---|
| `type` | URI identifying the problem type; dereferenceable documentation. |
| `title` | Human summary of the *type* — stable across occurrences, so it is derived from the status. |
| `status` | Mirrors the HTTP status, so the body survives being logged on its own. |
| `code` | **Stable machine code**, `SCREAMING_SNAKE`, never translated. See below. |
| `detail` | Translated explanation of *this* occurrence. Free to change. |
| `errors` | Every problem, each `{code, message, meta?}`. The first mirrors the top-level code/detail. |
| `validationIssues` | Field-level problems, each `{field, code, message}`, for a form to highlight. |

**Stable codes are the point.** `errors: ["Cannot delete this record because it is linked with…"]` is
a translated sentence: an MCP agent, a headless storefront or a retry policy cannot branch on it, and
it changes when someone edits a language string. A `code` can be branched on, and **changing one is a
breaking change**. Messages are free to change; codes are not.

**One parser, still.** Status `>= 400` → problem shape. That is how every HTTP client already works,
so this is less client code than an always-200 envelope, not more.

### The type that emits this

`classes/ConfigboxApiResponse.php` — `ok()`, `created()`, `badRequest()`, `forbidden()`, `notFound()`,
`conflict()`, `validationFailed()`, `serverError()`, then `->send()`.

**`ConfigboxJsonResponse` is unchanged and stays supported.** It is in heavy use in customizations and
custom tasks, and its `{success, feedback, errors, validationIssues, …}` contract is frozen. The two
coexist: an endpoint uses the new type once it has been migrated, and until then it does not.

`ConfigboxApiResponse::getEnvelopeSchema()` returns the JSON Schema (draft 2020-12) of both shapes as
a `oneOf`. It is hand-written rather than generated *from* the code on purpose — it is the contract,
and a schema derived from the implementation cannot catch the implementation drifting from it. Pass a
per-endpoint payload schema to slot it into `data`.

### Setting a status actually requires the platform

`http_response_code()` does **not** survive on Joomla: the host emits its own headers from the
application object afterwards and overwrites it. This was not theoretical —
`controllers/testautomation.php` called `http_response_code(403)` for years and clients received
`200 text/html`. Statuses therefore go through `InterfaceKenedoPlatform::setResponseStatus()`, which on
Joomla sets the specially-handled `status` header on the application.
## 3. Transport

- **Endpoint:** `/cb-api/<controller>/<task>` (SEF) or
  `index.php?option=com_configbox&controller=<controller>&task=<task>&output_mode=view_only`.
  Both already work; the system plugin maps `output_mode=view_only` to the host's raw document.
- **`Content-Type: application/json`** on every response.
- **Parameters** are ordinary GET/POST fields. Mutating tasks should be POST; the framework reads
  both through `KRequest`, so this is a convention clients can rely on rather than an enforcement.
- **Idempotency:** reads are safe to repeat. Mutations are not, and the contract does not pretend
  otherwise — a client that needs at-most-once semantics must carry its own key.

---

## 4. Authentication and session

Two separate things, currently conflated.

**Session (who the cart belongs to).** CBX keeps a DB-backed session keyed by a cookie. A
headless client that holds a cookie jar gets a working, persistent cart with no further work —
**verified**: two requests sharing a jar share one cart and one guest user, while a fresh jar starts
its own. A guest CBX user is created on first need. An explicit session token would be more
idiomatic for an API, but it is a *nicety, not a blocker*, and is out of scope here.

**Authentication (who the person is).** `user::loginUser` must answer with the §2 envelope like
everything else, `data: {userId, …}`, and must not redirect. `return_success` / `return_failure`
redirect parameters stay supported for the classic form post, but must never apply when the request
asks for the API — that is the same "one task, several response modes" defect as §6.

Admin endpoints keep the host's own permission model (`isAuthorized()` → `com_configbox.core.manage`).
The contract does not weaken it; it only fixes the shape of the answer, including the refusal.

---

## 5. Errors — which status

| Situation | Status | `code` example |
|---|---|---|
| Did what was asked | `200` (`201` if it created something) | — |
| A parameter is missing or unusable | `400` | `NO_RECORDS_SELECTED` |
| We do not know who you are | `401` | `AUTHENTICATION_REQUIRED` |
| We do, and you may not | `403` | `NOT_PERMITTED` |
| No such controller, task or record | `404` | `NOT_FOUND` |
| Well-formed, refused by the current **state** | `409` | `RECORD_IN_USE` |
| Well-formed, the **data** is not acceptable | `422` | `VALIDATION_FAILED` + `validationIssues` |
| We broke | `500` | `INTERNAL_ERROR` (log identifier in `detail`, never a stack trace) |

The 409-vs-422 line is worth keeping straight: `422` means *fix your input and retry*; `409` means
*the input is fine, something else has to change first*. "Cannot delete, other records reference it"
is `409` — retrying it unchanged will fail identically until those references go.

---

## 5b. The details that bite later

**Money is never a JSON number.** `"result": 149.7` is a float: `0.1 + 0.2` problems, and JavaScript
parses every number as a double. CBX already learned this internally — the schema uses `DECIMAL`
and the legacy `FLOAT` columns are on record as a mistake. The principle: money crosses the API as
**decimal strings plus an ISO 4217 code**, never a float. The shipped concrete form is the runtime
API's money object — nothing emits a bare `{"amount", "currency"}` pair:

```json
"price": { "net": "100.00", "gross": "119.00", "tax": "19.00", "currency": "EUR", "formatted": "€ 119.00" }
```

**Translations are flat keys, and no record key contains a dash.** A record carries one key per
translatable field per active language, underscore-separated: `title_en_GB`, `title_de_DE` — the
same keys the model hydrates and the admin form inputs post (`<input name="title_en_GB">`). The
API serves exactly that shape:

```json
{ "id": 1, "sku": "CBX-0001", "title": "Workbench",
  "title_en_GB": "Workbench", "title_de_DE": "Werkbank" }
```

The **plain** `title` holds the text for the language the read asked for (`?language=de-DE`, else
the store default); the per-language keys carry all of them. A collection read is projected down to
its listing columns and so carries the plain key only — ask for the record when you want every
language.

An earlier revision of this contract nested translations under their field
(`"title": { "en_GB": … }`); that is gone — flat won, because one shape shared by the model, the
admin forms and the API means nothing to project on the way out and nothing to unpack on the way
in. Every key is a valid identifier: dot-accessible, destructurable, greppable. Sending the nested
form now answers **422**, naming the key to use instead: a shape that is silently ignored is a shape
that comes back.

**The language part is underscored — `en_GB`, never `en-GB`.** `KenedoLanguageHelper::getTagKey()`
is the one derivation that turns a BCP 47 tag into the key suffix, shared by the record keys, the
admin form names and the API, so none of them can drift. The cost is a conversion for a client
matching against `Accept-Language` (where the tag has a dash) — that conversion is the client's
one-liner, and the **write** path accepts the same flat keys it serves, so round-tripping a record
needs no reshaping at all.

> This section used to argue the opposite, and was right when it was written: the keys **were**
> hyphenated until the 2026-07-27 conversion moved every one of them onto `getTranslationKey()`. The
> argument it made — that a BCP 47 tag should survive intact — lost to the fact that
> `record.title-en-GB` is a subtraction, not a property access. `specs/backend/record-tasks.spec.ts`
> pins the result.

**Partial updates.** `store` fills in what a request did not send from the record as it stands, so
sending only the field you want changed no longer blanks the rest. A field sent empty still clears —
that is how you clear one, and it is what a form post does.

**Naming is `camelCase` at the boundary — on the runtime surface.** The runtime endpoints hold to
it (`cartPositionId`, `openQuestions`), even where a legacy parameter like `prod_id` is still
accepted on the way in. The entity CRUD surface deliberately does **not**: its records carry the
model's raw column keys (`page_id`, `question_type`), because those are the same keys the admin
form posts, the generated schemas describe and the record stubs name — one shape shared end to end
beats a translation layer that splits it in two.

**Dates are ISO 8601 with an offset** (`2026-07-25T14:03:00Z`). Never Unix ints, never local time.
Some `created` columns are not even `DATETIME`; that must not reach the API. Status: holds on the
runtime endpoints; the entity CRUD reads serve column values as the model hydrates them — the same
raw-column deliberateness as the key naming above.

**Nulls are present, not omitted.** Always include the key and use `null` for "no value", so omission
reliably means "this endpoint does not have that field".

**Versioning: `/cb-api/v1/…` in the path**, decided now even though the answer is "v1 for the
foreseeable future". Retrofitting a version is far worse than carrying an unused one.

**Bulk operations are all-or-nothing.** `delete` takes `ids` plural, and `KenedoModel::delete()`
pre-checks every id and refuses the whole batch if any is blocked. That is a fine answer — but it is
now a *documented decision*, because the alternative (per-id results) is a different response shape
and is not something to discover later.

---

## 6. Admin CRUD

**The rule: a mutation returns the result of the mutation, and nothing else.** It never renders a
list, never redirects, never emits HTML. A UI that wants a refreshed list makes a *second* call.

That is the change with the widest blast radius, and it is deliberate: it decouples "what happened"
from "what to show next", which is what makes the same endpoints usable by the admin UI, the MCP
server and a third-party client without special-casing.

| Task | Status | `data` payload |
|---|---|---|
| `store` (create) | `201` | `{id, wasInsert: true, record, redirectUrl?}` |
| `store` (update) | `200` | `{id, wasInsert: false, record, redirectUrl?}` |
| `delete` (`ajaxDelete` is an alias) | `200` | `{ids, deleted}` |
| `publish` / `unpublish` (`ajaxPublish` is an alias) | `200` | `{ids, published}` |
| `copy` | `201` | `{ids, newId, redirectUrl}` |
| `storeOrdering` | `200` | `{ordered}` |
| `getRecords` | `200` | `{items, total, offset, limit}` — `total` is the count BEFORE paging |
| `getRecord` | `200` | `{record}` · `400` no id / unknown language · `404` no such record |

`redirectUrl` is **advisory**: where a client may want to go next. The server does not redirect.

### `adminproducts` only — the cascading delete

Two tasks that exist on the products controller and nowhere else. `delete` above is unchanged and
still refuses a product that has pages or detail panes, which is what the REST controller, the MCP
tools and the CLI rely on; these are the deliberate way through, and the admin reaches them only
from a dialog that made the operator type a confirmation word.

| Task | Status | `data` payload |
|---|---|---|
| `deleteCascadePreview` | `200` | `{products: [{id, title}], counts: {calculations, answers, questions, pages, detailPanes, examples, files}, blockers: string[], dialog: {…}}` · `404` no such product |
| `deleteCascade` | `200` | `{deleted: {calculations, answers, questions, pages, detailPanes, examples, products}, products: int[]}` · `404` no such product · `409` `RECORD_IN_USE` |

Both keyed by ENTITY, never by model class name — `counts` and `deleted` use the same words so the
preview and the result describe the same thing. Both are generated from one level list
(`ConfigboxProductDeleteHelper::getLevels()`), so they cannot drift apart; `counts` omits `products`
because it answers "what goes WITH the product you picked".

**The keys above are the stock graph, not a closed set.** The cascade reads what belongs to a product
off the models themselves, so an install whose customization adds a child list to the product form
gets one key more, named after that model (`LEVEL_KEYS` pins the stock names; anything else is
derived from the model class). Read the keys you know and ignore the rest. A customization whose
records should *not* be taken by a product delete says so with `'deleteWithParent' => false` on that
property.

`blockers` is what stops the delete: a record outside the plan that still needs one inside it. It is
computed for the preview *and* re-checked by `deleteCascade` before it opens its transaction, so a
client may show it but must not rely on having asked. A non-empty `blockers` means `deleteCascade`
would answer `409`.

`dialog` is display copy composed server-side, where the translations are — title, lead, item list,
prompt, confirmation word and button labels. A client renders it and holds no wording of its own.

`counts.files` is how many uploaded images and files go with the records — asked of the properties
that own them, removing nothing. It is worth showing separately: rows can be brought
back from a database backup, an uploaded image cannot.

Deleting is transactional over rows **and files**. A property reports the files it owns rather than
unlinking them (`KenedoProperty::getDeletionFilePaths`), `KenedoModel::delete()` queues them, and
`KenedoDatabase::deferUntilCommit()` removes them only when the OUTERMOST transaction commits — so a
refusal at any level leaves the install exactly as it was, on disk as much as in the tables. That
applies to every KenedoModel delete, not only this one.

Retired, with their replacements:

- `show_list=1` (render the list instead of answering) → **the caller issues a second, explicit list
  request.** The admin JS becomes: mutate → check the status → reload list. Gone from `delete` and
  `copy`; it never had any senders.
- `publish` calling `$this->display()` → same. It also never checked whether the publish had worked.
- `quickedit=1` (redirect to `HTTP_REFERER`) → same; the caller decides where to go.
- `redirectUrl` in a store response → moves into `data`, and is advisory. The server does not
  redirect; the client may.

---

## 7. Headless configurator

The end-user flow must be completable with an HTTP client and a cookie jar, with no HTML parsing.
**This is done.** The tasks below are live, answer with the envelope, and never redirect; the
journey is asserted end to end by `tests/specs/api/headless-configurator.journey.spec.ts`.

| Step | Endpoint | `data` payload |
|---|---|---|
| Start a configuration | `configuratorpage/startConfiguration` (`prod_id`) | `{cartPositionId, productId, cartId, firstPageId, product, pages[], openQuestions[], complete}` — **201** |
| Read the structure | `configuratorpage/getStructure` (`prod_id`) | `{product, pages[{id, title, questions[{…, answers[]}]}]}` |
| Read live questions | `configuratorpage/getQuestions` (`cartPositionId`) | `{questions[], count}` — with `applies`, `selection`, `outputValue` |
| Why can't I have this | `configuratorpage/explainQuestion` (`questionId`) | `{applies, rule{conditions[], blockers[]}, answers[], blockedBy[]}` |
| What would this do | `configuratorpage/previewSelection` (`questionId`, `selection`) | `{committed:false, wouldBeAccepted, effects{}, openQuestions{before,after}, price{before,after}}` |
| What gates what | `configuratorpage/getDependencies` (`prod_id`) | `{questions[{dependsOn[], affects[]}], roots[], decisionOrder[], cycles[]}` |
| What is still open | `configuratorpage/getOpenQuestions` (`cartPositionId`) | `{openQuestions[], count, complete}` |
| Where things stand | `configuratorpage/getConfiguration` (`cartPositionId`) | `{selections[], openQuestions[], complete, price{}, quantity, finished}` |
| Choose | `configuratorpage/setSelection` (`questionId`, `selection`) | `{accepted, question{}, previous{}, autoChanges{}, openQuestions[], price{}}` |
| Finish the configuration | `configuratorpage/finishConfiguration` (`cartPositionId`) | `{cartPositionId, finished, cart{}, cartUrl}` |
| Read the cart | `cart/getCart` | `{cartId, positions[], itemCount, totals{}}` |
| Change quantity | `cart/setCartPositionQuantity` | `{cartPositionId, quantity, position{}, cart{}}` |
| Copy / reopen / remove | `cart/copyPosition`, `cart/editPosition`, `cart/removePosition` | `{cartPositionId, …, configuration{}}` / `{…, cart{}}` |

Every projection is built by `ConfigboxConfiguratorApiHelper`, so an in-process caller — the MCP
server, the CLI — gets the same shapes without going through HTTP.

### Finding a configuration, not just reporting one

The reads above describe state. Three more exist because state is not enough in a product with real
rules: a client that is told `applies:false` knows it cannot have something, and nothing about what
to do instead. Searching a configuration space by trial and error against `setSelection` does not
work either, because rules clear other selections as a side effect and changing your mind does not
bring them back.

- **`explainQuestion`** takes the governing rule apart. Each condition is reported with its subject
  question, operator, expected value and current value, and whether it currently holds; the failing
  ones are flattened into `blockedBy`. That turns "unavailable" into "select *Option A* for
  *Colour*". Answers are explained the same way, because a question can apply while the answer you
  want does not — the case that most often needs explaining.

  Per-condition verdicts come from each condition type; the **overall** verdict comes from the
  engine, so the explanation can never disagree with the store about bracket and combinator
  precedence.

- **`previewSelection`** answers "what would happen" and changes nothing. It reports which questions
  would appear or disappear, which selections the rules would clear or swap, what would still be
  open and what it would cost — `before` and `after`, side by side. Built on the engine's own
  *simulated* selections, which are memory-only for the request (`storeSelectionsInSession()` writes
  `getSelections(false)`, which excludes them), and cleared in a `finally` so a preview cannot poison
  later reads in the same request even on the way out of an exception.

  A value the store would refuse comes back `wouldBeAccepted:false` with the reason as a **200**:
  "that would not be accepted" is the correct answer to "what would happen", not a failure.

- **`getDependencies`** hands over the influence graph instead of making a client rediscover it.
  `decisionOrder` is a topological sort — decide in that order and nothing you choose is invalidated
  by something you choose later. Rules that reference each other cannot be ordered and are reported
  in `cycles` rather than jammed into the sequence.

Two smaller things in the same spirit:

- **An answer carries `selectable`, not only `applies`.** Availability is a separate axis: an answer
  can pass every rule and still be blocked because the store flagged it unavailable
  (`available` = 0 with `disable_non_available`). A client reading only `applies` picks it and fails.
- **`explainQuestion` exposes the store's rule logic** to anyone with a cookie jar. Deliberate: it is
  business configuration, not customer data, and the storefront already reveals all of it through
  behaviour — a client can map the same rules by trial and error, just slowly.

### Decisions worth knowing

- **New task names, not converted ones.** `cart::addProductToCart`, `removeCartPosition`,
  `editCartPosition` and `copyCartPosition` are reached as **plain links from the storefront
  templates**; their job is to change something and put the customer on the next page. Converting
  them means reworking the cart and product templates, which is its own pass. The tasks above do the
  same mutations through the same model methods and answer with data. `setCartPositionQuantity` was
  the exception and was migrated in place: it was already XHR-only with a single caller.

  **A parallel surface needs the same guards, and did not have them.** The two flows drifted: the
  runtime tasks checked ownership *and* `editOrder`, while `copyCartPosition` checked neither (a
  guessed id wrote an unfinished line into a stranger's cart) and the other two links checked only
  ownership, so an ordered line could be reopened or removed through a URL the template no longer
  renders. Both flows now ask both questions. If you add a task on one side, add the guards on the
  other — the deprecated flow is the one nobody looks at, which is exactly why it drifts.

- **`makeSelection` stays as it is; `setSelection` is the projection.** The raw payload is a set of
  instructions for the configurator's DOM. `setSelection` runs the same engine and reports the
  *outcome*: what is selected now, what the rules moved as a consequence (`autoChanges`), what it
  costs, what is still open.

- **`autoChanges` is not optional reading.** Choosing one thing can clear or replace another. A
  client that assumes only the question it named changed will report a configuration the store does
  not have.

- **Confirmation is a 200, not an error.** When a change would undo selections the customer already
  made, the answer is `{accepted:false, needsConfirmation:true, confirmationText, affects[]}` and
  **nothing has changed**. Repeat with `confirmed=1`. The request was fine and the server did the
  right thing — that is not a failure status.

- **`languageTag`, not `language`.** On the **site** application Joomla's language filter consumes a
  request variable called `language` before the component sees it, so `language=de-DE` silently
  yielded the default language. The runtime tasks read `languageTag` (which `makeSelection` already
  used) and keep `language` as a fallback. The admin surface keeps `language`: it runs under
  `/administrator`, where the filter is not in play.

- **Single-language strings.** Unlike the authoring surface (§6), a runtime response resolves every
  translatable string to one language. A configurator client shows the customer one language, and
  loading every translation of every answer of every question would cost real queries for data
  nobody reads.

- **Money is `{net, gross, tax, currency, formatted}` with decimal strings.** A JSON number is a
  double and a double cannot hold 0.1; a client adding up line items in floats produces totals that
  are a cent off and cannot say why. `formatted` is display sugar in the store's locale — never
  parse it. The amounts do not change with the language; only `formatted` does.

- **A foreign cart position is a 404, not a 403.** "It exists but is not yours" is a way to count
  the store's carts.

- **The cart total is the items total.** Deliberately no grand total: delivery and payment are only
  priced once the customer picks them in checkout, and a "total" that silently excludes shipping is
  worse than none.

- **A cart line lists only the selections marked "show in overview".** That mirrors what the cart page
  shows and is what the setting is for, but it means `getCart` is a *summary*: for the complete
  configuration of a line, call `getConfiguration` with its `cartPositionId`.

### Found on the way: the engine does not validate answer ids

`ConfigboxQuestion::isValidValue()` checks bounds, input restrictions and upload rules, but it never
checks that a selection is **one of the question's own published answers**. `makeSelection` will
therefore store the id of an answer belonging to a *different* question, or one that does not exist
at all, leaving a cart position holding a selection that renders as a raw number and prices at zero.
The storefront never sends such a value, which is why it has gone unnoticed.

There is a second, worse version of the same gap one level up: nothing checked that the **question**
belongs to the product being configured. Sending a question from a different product together with
one of *its* own real answer ids satisfied every existing check — the engine recorded the selection,
`ConfigboxConfiguration` silently dropped it again on reload (it filters selections to the product's
own questions), and the caller received `200 accepted:true` with the other product's label echoed
back. A success reported for a change that did not happen is worse than an error, because nothing
looks wrong.

`setSelection` refuses both: `422 UNKNOWN_ANSWER` (see
`ConfigboxConfiguratorApiHelper::isSelectableValue()`) and `422 QUESTION_NOT_IN_PRODUCT` (see
`questionBelongsToProduct()`). Two more refusals sit beside them for values the engine would take
and then betray: `422 ANSWER_NOT_SELECTABLE` for an answer that exists but is currently disabled by
a rule or flagged unavailable (the engine records it and the next rules pass quietly clears it), and
`422 VALUE_OUT_OF_BOUNDS` for a value outside the question's own advertised `constraints` (which the
engine would store and price — refusing it is what makes `getStructure`'s bounds mean something).
`getStructure` likewise refuses a `prod_id` and `cartPositionId` that
disagree, which would have built one product's tree against another's configuration.

These guard the new boundary **without** touching the shared engine path the live configurator runs
on — tightening `isValidValue()` is a change that deserves its own pass with the storefront's tests
green. Recorded here so it is not mistaken for a fixed problem: `makeSelection` still accepts both.

### Still not covered

Checkout. `cart/checkoutCart` and the checkout steps still render pages, so the flow above stops at
the cart. That is the next piece, and the larger one — it drags in addresses, delivery, payment
methods and the PSP bridge.

---

## 8. What has to change

The implementation checklist. Each row has a contract spec that is `fixme` until it lands.

| # | Change | Where |
|---|---|---|
| 1 | ~~Add `data` to the envelope~~ **DONE, differently** — `ConfigboxJsonResponse` is frozen and untouched; the new `ConfigboxApiResponse` carries `data`/`meta` and problem details | `classes/ConfigboxApiResponse.php` |
| 2 | ~~`store()` emits the envelope~~ **DONE** — `201` on insert / `200` on update with `data:{id, wasInsert, record, redirectUrl}`, `422 VALIDATION_FAILED`, `409 STORE_FAILED`, `500 INTERNAL_ERROR` when the model fails without saying why | `KenedoController::store()` |
| 3 | ~~`ajaxPublish` emits the envelope~~ **DONE** — folded into `publish` as a deprecated alias, exactly like `ajaxDelete` into `delete` | `KenedoController` |
| 4 | ~~`delete()` drops `show_list` and `quickedit`; always JSON~~ **DONE**, and migrated to status codes: `200` + `data.ids`, `400 NO_RECORDS_SELECTED`, `409 RECORD_IN_USE` — `ajaxDelete` folded in as an alias; the four quick-edit buttons now call the endpoint and reload; `adminorders::remove()` likewise | `KenedoController::delete()` |
| 5 | ~~`copy`, `storeOrdering` payloads move into `data`~~ **DONE** — `copy` `201` + `data:{ids, newId, redirectUrl}`; `storeOrdering` `200` + `data:{ordered}`, `400 INVALID_ORDERING` for unreadable input, `500 ORDERING_FAILED` instead of an uncaught DB exception on an unorderable model | `KenedoController` |
| 6 | ~~Admin JS: mutate → check → reload list~~ **DONE for the migrated tasks** — the form handler, the list handler, the publish toggle (which used to ask `publish` to render the list) and `productTree.js` all read the STATUS now | `assets/kenedo/kenedo.js`, `assets/javascript/productTree.js` |
| 7 | ~~`loginUser` emits the envelope; no redirect in API mode~~ **DONE** — `200` + `data:{loggedIn, userId}` / `401 LOGIN_FAILED`; the login form's `return_success`/`return_failure` are navigated to by the client. `logoutUser` answers `data:{loggedOut}` | `controllers/user.php` |
| 8 | `getMissingSelections*` return `{missing:[…]}` | Left as the browser's bare array; **`getOpenQuestions`** is the API read and says *why* each one is open |
| 9 | ~~`makeSelection` gains a stable projection~~ **DONE, alongside** — `setSelection` runs the same engine and reports the outcome (`autoChanges`, `price`, `openQuestions`); `makeSelection` is untouched | `controllers/configuratorpage.php` |
| 10 | ~~Cart tasks answer with the envelope~~ **DONE via new tasks** — `removePosition`, `editPosition`, `copyPosition` and `getCart`; `setCartPositionQuantity` migrated in place. The four link tasks the templates use still redirect | `controllers/cart.php` |
| 11 | ~~New `configuratorpage/getStructure`~~ **DONE**, plus `startConfiguration`, `getQuestions`, `getOpenQuestions`, `getConfiguration`, `setSelection`, `finishConfiguration` | `controllers/configuratorpage.php`, `helpers/configuratorapi.php` |
| 12 | ~~New `cart/getCart`~~ **DONE** | `controllers/cart.php` |
| 13 | ~~`testautomation` adopts the envelope~~ **DONE** — `sendJson()` emits the envelope (the legacy `success` key is dropped at the seam), failures use problem+json with real statuses (403 guard, 400 missing/invalid parameters, 404 unknown records, 500 with the message), and presence reads answer `200` with a `null` field rather than an error. The suite's `ApiClient` branches on the status, once, in `call()` | `controllers/testautomation.php`, `tests/support/api-client.ts` |
| 14 | ~~MCP tools return the envelope's `data` unchanged~~ **DONE, via the shared layer** — since `ConfigboxApiRecord::project()` moved into `ConfigboxEntityApiHelper`, the service's return value IS the wire shape, and the MCP tools hand it over untouched (`flattenForTool` only translates the typed exceptions into prose, which is MCP's own boundary). `entity-api.spec.ts`'s "same input through HTTP and through MCP" pins it | `helpers/mcp.php`, `helpers/entityapi.php` |
| 15 | `setResponseStatus()` platform seam | ~~all five platforms~~ **DONE** |
| 16 | Remove `success` from the remaining frontend JS call sites | per endpoint, as each migrates |
| 17 | ~~Custom admin endpoints: currencies, dashboard, config, calc matrices, charset fixer~~ **DONE** | `controllers/admin*.php` |
| 18 | ~~Read tasks `getRecord`/`getRecords` with dash-free records~~ **DONE** | `KenedoController`, `classes/ConfigboxApiRecord.php` |
| 19 | ~~`store` supports partial updates~~ **DONE** | `KenedoController::store()` |
| 20 | ~~Frontend quick-edit goes through the `configbox/server` choke point~~ **DONE** | `helpers/quickedit.php` |
| 21 | ~~A surface a program can actually call~~ **DONE** — the REST entity API at `/cb-api/v1/`: resource URLs, HTTP verbs, RFC 6750 bearer tokens, OpenAPI 3.1 over the committed schemas. The task endpoints stay as they are; this sits beside them on the same pipeline | `controllers/apiv1.php`, `helpers/entityapi.php`, `helpers/apitokens.php`, `helpers/openapi.php` |

### Still muddy — what is left

Most of the list is done. What remains, and why:

| Task | State |
|---|---|
| `adminorders::update_status` / `release_invoice` / `insert_invoice` | **DONE** — answer with data; the order screen's JS calls, reads and then decides |
| `adminorders::cancel` | Left. It is navigation-only, not a mutation, so it is not ambiguous — just a server round-trip for something the client could do itself. |
| `adminlicense::storeLicenseKey` | **DONE** — stores and says so; `adminLicense.js` navigates |
| `adminpostinstall::*` (5 tasks) | **DONE** — envelope + real `validationIssues`; `postinstall.js` on the choke point |
| `adminmvcmaker::createMvcCode` | **DONE** — was plain text |
| `copy()`'s `show_list` branch | **DONE** — removed with `delete()`'s |
| `user::loginUser` | **DONE** — one answer for every caller; the login form's `return_success`/`return_failure` are navigated to by the client |
| `user::store` | **DONE** (2026-07-31) — the base class helpers answer for it: `200` + `data:{id}` with `meta.feedback`, `422` with `validationIssues` (the model's field-level issues ride the exception), `500 STORE_FAILED`. `user.js` saves over the choke point; `customerform.js`'s `displayValidationIssues()` accepts both `field` (problem shape) and `fieldName` (model shape), so customization callers keep working. |
| cart tasks | **DONE, additively** — §7's flow is complete through the cart. `setCartPositionQuantity` was migrated in place; `removePosition` / `editPosition` / `copyPosition` / `getCart` are new envelope tasks beside the template's link tasks, which still redirect. |
| the four cart LINK tasks | **DONE** (2026-07-31) — `addProductToCart` answers the envelope in place (`201` + `data:{cartPositionId, complete, redirectUrl, cart?\|missingSelections?}`); the stock links are intercepted by `configbox/cart::initLinkTasks` (matched by **task name in the href**, so customization templates keep working) which calls the envelope tasks and navigates on the data. `editPosition`/`copyPosition` carry a routed `configuratorUrl` as data for exactly that. The three legacy redirect tasks stay reachable for JS-off browsers, deprecated and logged. No template changed — the interception made that unnecessary. |
| checkout tasks | Left — now the biggest piece. `checkoutCart` and the checkout steps render pages. Drags in addresses, delivery, payment methods and the PSP bridge. |

Two component-level authorization gaps found and fixed in the same pass:
`admindashboard::removeFileStructureWarning` and `adminconfig::renewWordpressPages` never called
`isAuthorized()`. Joomla's admin area still gated them — an anonymous request gets a 403 from the
host — so this was not an open door, but it meant any logged-in backend user could call them
regardless of their CBX permissions. `KenedoController::execute()` has no global gate; every
task authorizes itself, so a missing call is silent.

---

## 9. Rollout

The frontend and the admin UI both consume today's shapes, so this cannot land as one commit.

1. **Characterization tests first** — pin today's behaviour so it can be changed knowingly.
2. ~~Envelope gains `data`~~ → a **separate type** (`ConfigboxApiResponse`), because `ConfigboxJsonResponse` is frozen for customizations. Additive by construction: nothing that uses the old type changes.
3. **Endpoint by endpoint**, each with its contract spec flipping from `fixme` to green, and its
   consumer updated in the same commit.
4. **The HTML/redirect modes go last**, once no consumer depends on them — they are the only
   genuinely breaking removals.

A response may carry both the new `data` and the old top-level keys during the transition; the
duplicates come out when step 4 does.

---

## 10. See also

- `docs/technical/com_configbox_runtime_api.md` — §7 written for someone building a client: the
  configurator and cart flow, worked through call by call.
- `docs/technical/com_configbox_entity_api.md` — the REST entity API at `/cb-api/v1/`, and the one
  pipeline it shares with the MCP server and the CLI.
- `docs/technical/com_configbox_mcp_server.md` — the MCP server, the first consumer written against
  this contract.
- `docs/technical/com_configbox_kenedo_controller.md` — how a task ends, and the raw output mode that
  makes JSON responses possible.
- `docs/platform/joomla/com_configbox_sef_urls.md` — how `/cb-api/` routes.
- `tests/docs/SPECS.md` — where the characterization and contract specs live.
