# Scenario-resolved legal documents (multi-language)

**Status:** deployed to **stage** (`fn-publicweb-stage`, 2026-08-20 19:42 UTC). **Not** in production.
**Tickets:** #24 (design) · #30 (backend) · #36 (response fields) · #28/#46 (SDK origin)

## Live demos

Each page isolates **one** integration pattern, so nothing else on the page can be doing
the work for it. Every page has a language switch and a provenance panel.

| | Demo | Shows |
|---|---|---|
| **index** | <https://demo-3.consentizer.cz/demo/> | Hub, and how to read the provenance panel |
| 1 | <https://demo-3.consentizer.cz/demo/legacy.html> | The existing GTM snippet, **verbatim** — proof no deployed integration must change |
| 2 | <https://demo-3.consentizer.cz/demo/attribute.html> | `data-waulter-document`, including an element injected after load |
| 3 | <https://demo-3.consentizer.cz/demo/custom-js.html> | Reading the provenance attributes yourself, plus the `dataLayer` events |
| 4 | <https://demo-3.consentizer.cz/demo/types.html> | Cookie policy and privacy policy from **one** scenario |

Append `?lang=cs` to any of them to make the scenario switch observable.

---

## 1. What it does

One page markup, one agreement id — and the visitor gets the document in the right
language for the page they are on.

The page author writes **one** id. The backend may serve a **different** document
from the same family, chosen by the configuration's scenario rules and the page URL.

```
Author writes:   AG0998   (the Czech cookie policy)
Visitor on:      https://demo-3.consentizer.cz/        →  served AG0292  (en)
Visitor on:      https://demo-3.consentizer.cz/?lang=cs →  served AG0998  (cs)
```

The declared id is not replaced by the scenario — it is the **fallback**. If there is
no scenario, or resolution fails, or the resolved document is missing or of the wrong
type, the declared document is served. The feature can only ever *change* which
document you get, never leave you with none.

---

## 2. Usage A — the data attribute (preferred)

Put the attribute on any element. No script, no function call, no ordering concern.

```html
<div data-waulter-document="AG0998"></div>
```

The SDK scans for these at start, and a `MutationObserver` catches elements added
later — so it works with client-side routing and late-rendered content.

### Attributes the SDK writes back

| Attribute | Values | Meaning |
|---|---|---|
| `data-waulter-document-state` | `loading` \| `loaded` \| `error` | Lifecycle. Also the idempotency claim — see §6. |
| `data-waulter-document-resolved-from` | `scenario` \| `requested` \| `fallback` | **How** the served document was chosen. |
| `data-waulter-document-resolved-id` | e.g. `AG0292` | **Which** document was actually served. |
| `data-waulter-document-language` | e.g. `cs`, `en` | Language slot chosen. Omitted when the backend does not report one. |

`resolved-from` is the field to read when you want to know whether resolution
actually happened, rather than inferring it from the text.

- `scenario` — the scenario was evaluated and chose this document.
- `requested` — no resolution was attempted; you got what you asked for.
- `fallback` — resolution was attempted and **failed**; you got the declared
  document. Check `resolvedReason` in the API response for why.

---

## 3. Usage B — the legacy JS call

Still supported, and routes through exactly the same code path as the attribute.

```html
<div id="waulterCookies"></div>
<script>
(function () {
  var check = function () {
    return window.WaulterSDK && window.WaulterSDK.appendDocument instanceof Function;
  };
  var fill = function () {
    window.WaulterSDK.appendDocument("waulterCookies", "AG0998");
  };
  if (check()) { fill(); return; }
  var start = Date.now();
  var timer = setInterval(function () {
    if (Date.now() - start >= 8000) { clearInterval(timer); return; }
    if (check()) { clearInterval(timer); fill(); }
  }, 100);
})();
</script>
```

`appendDocument(elementId, agreementId)` takes an element **id**; the attribute form
takes an element. Everything else — resolution, provenance attributes, diagnostics,
`dataLayer` events, failure handling — is identical.

**Prefer the attribute.** The polling above exists only because a script has to wait
for the SDK; the attribute has no such ordering problem.

---

## 4. Usage C — custom JS on the resolved result

The provenance attributes are the integration surface. Watch them rather than racing
the fetch — `appendDocument` is async and its duration depends on document size.

```js
var el = document.getElementById("waulterCookies");

new MutationObserver(function () {
  var state = el.getAttribute("data-waulter-document-state");
  if (state !== "loaded") return;
  console.log({
    from:     el.getAttribute("data-waulter-document-resolved-from"),
    servedId: el.getAttribute("data-waulter-document-resolved-id"),
    language: el.getAttribute("data-waulter-document-language")
  });
}).observe(el, {
  attributes: true,
  attributeFilter: [
    "data-waulter-document-state",
    "data-waulter-document-resolved-from",
    "data-waulter-document-resolved-id",
    "data-waulter-document-language"
  ]
});
```

> **Do not poll on a fixed window.** An earlier version of the demo panel polled 30×
> at 200 ms and then stopped, freezing whatever it last read — so any fetch slower
> than six seconds displayed `loading` permanently and looked like a failure.

### dataLayer events

Both entry points push to `window.dataLayer`:

```js
{ event: "waulter.document.loaded", elementId, requestedAgreementId, resolvedAgreementId }
{ event: "waulter.document.error",  elementId, requestedAgreementId, reason }
```

`reason` is one of `empty_agreement_id`, `no_element`, `no_content`, `fetch_failed`,
`sdk_never_started`.

---

## 5. The API contract

```
GET {API_ORIGIN}/web/agreement?agreementId=AG0998&id=SC00009&url=<page url, encoded>
```

| Request param | Required | Meaning |
|---|---|---|
| `agreementId` | yes | The declared document — and the fallback. |
| `id` | no | A `ccid` **or** an `SC####` scenario id. Without it, no resolution is attempted. |
| `url` | no | The visitor's page URL. What the scenario rules are evaluated against. |

| Response field | Always present | Meaning |
|---|---|---|
| `content` | yes | The document HTML. |
| `agreementId` | yes | |
| `resolvedFrom` | yes (post-#30) | `scenario` \| `requested` \| `fallback` |
| `resolvedAgreementId` | yes (post-#30) | The document actually served. |
| `language` | when known | Language slot. |
| `resolvedReason` | on fallback | Why resolution failed. |

`resolvedFrom` and `resolvedAgreementId` being **unconditional** is deliberate: their
absence is an unambiguous signal that #30 is not deployed to the environment you are
talking to. That is how the stage/production deployment state was established.

`id` and `url` are additive — a pre-#30 backend accepts and ignores them and returns
its normal response, so sending them early is safe.

---

## 6. Behaviour you should rely on

**Fail-open, always.** Every failure path serves the declared document. A misconfigured
scenario degrades to "the visitor reads the document you named", never to an empty
container or an error message inside a legal text.

**Type is verified.** If a configuration points its cookie-policy slot at a privacy
policy, that is rejected and reported as `fallback` — serving the wrong *kind* of
legal document while claiming `resolvedFrom: "scenario"` would be worse than not
resolving at all (#62).

**Domain binding.** Resolution is bound to the resolved configuration's own domain:
the page host must match the config's `url` or its `whitelistedDomains`, else the
backend fails closed to `fallback`. A consequence worth knowing: **multi-language
resolution does not work in a localhost or DEV preview**, because a localhost href
owns no configuration. This is correct, not a defect — substituting a different host
would resolve against a configuration the page does not belong to.

**Fill is idempotent.** The first writer claims the element by setting
`data-waulter-document-state` synchronously; a second path finds it claimed and skips.
Scan and observer cannot double-fill.

**Content is injected raw.** `content` is Waulter's own published legal document from
Waulter's own API, not user input. It is deliberately **not** sanitised — stripping
markup from a legally published text is the worse failure. Recorded as an accepted
operator ruling, not an oversight.

**Id shape is a warning, never a gate.** Ids that do not match `AG` + digits are
requested anyway and the server decides. Rejecting client-side would break
hand-created or imported ids, and would make the attribute refuse ids that
`appendDocument()` accepts.

---

## 7. Known limitation

**#79 — the declared `agreementId` does not constrain resolution.** Any existing id
combined with `SC00009` and a demo-3 URL returns demo-3's document. The declared id
is treated as a fallback, not as a family constraint, so it does not scope what the
scenario may return. Open; not yet triaged.

---

## 8. Verifying a deployment

```bash
curl -s "https://api.stage.waulter.eu/web/agreement\
?agreementId=AG0998&id=SC00009&url=https%3A%2F%2Fdemo-3.consentizer.cz%2F"
```

Expect `resolvedAgreementId: AG0292`, `resolvedFrom: scenario`, `language: en`.
Swapping the URL for `...%2F%3Flang%3Dcs` must return `AG0998` / `cs`.

If `resolvedFrom` is **absent**, #30 is not deployed to that environment.

Stage and production are different function apps on different builds — a green on
one is not evidence about the other.
