A
?
← Handbook

Guardrails

The team must not be able to produce off-strategy creative. Not "should not" — must not.

The rule

A creative requires an approved message angle and an approved copy variant. There is no path around this, and three independent layers enforce it:

  1. Database. The creatives_on_strategy trigger rejects an insert referencing an unapproved angle or unapproved copy.
  2. Query. listApproved() returns approved angles carrying only approved copy, and drops any angle with no approved copy since it cannot produce a creative. An unapproved pairing is not hidden by the interface — it never reaches the caller.
  3. Route. /api/generate and /api/campaign/execute re-check, and reject an angle belonging to a different client.

Why three layers

Because they fail differently. UI-only enforcement is bypassed by curl. Route-only enforcement is bypassed by a second interface — which now exists, since the MCP server is a second front door. The database rule is the only one that holds no matter who is calling.

Creation is separate from approval

Anything created lands as draft. Approving an angle does not approve its copy; they are separate acts. This holds in the web UI and over MCP identically, because both call the same service function.

Proof on creatives

Only verified_external features may appear on a proof card. A claim that is true but unverifiable is real knowledge and still cannot anchor an ad.

Proof is fetched server-side from the knowledge base, never accepted from the caller — otherwise a request could inject a claim the client never made.

The gap that is still open

Text claims are validated; imagery is not. The first real generation produced a hinge carrying a competitor's brand mark while supporting a claim about a different supplier. Scene prompts must not name or imply a third-party brand, and nothing currently checks that.

WhatsApp is read-only, structurally

Alex, 20 Aug 2026: *"please DO NOT send anything to the wassenger as these are our LIVE client groups."*

There is no staging WhatsApp. A message sent through Wassenger arrives on a paying client's phone and cannot be recalled, and the account is a real device in the office that the whole team relies on. So the constraint is enforced in the transport, not in discipline: src/lib/whatsapp/wassenger.ts hard-codes method: "GET" and takes no method argument. There is no way to spell POST from inside that module.

This follows the same principle as the on-strategy trigger and the revoked DELETE grant: a rule that lives only in someone's memory is not a rule. If sending is ever wanted, it has to be a deliberate edit to that guard — which is precisely the kind of change that gets noticed.

Why not their MCP

Wassenger ships an official MCP server, and it is the wrong tool here. Its headline capability is manage_whatsapp_messages with eleven send action types — text, media, poll, template and more — pointed at the live number. Its safety story is that *send actions wait for your approval*, which is a confirmation prompt: a behavioural control that depends on a human reading a dialog correctly every single time, including at 2am when the dialog looks like the twenty before it.

Three reasons the REST path wins:

  1. A vendor's toolset cannot be trimmed. Connect that MCP and every session holding it gains send power over live client groups. We can decline the server; we cannot delete one of its tools.
  2. Structural beats behavioural. Our transport cannot express a write. Theirs can, and asks nicely first.
  3. Least authority. We need three read endpoints. The MCP grants an inbox.

Wassenger has no read-only API key — keys are per workspace and unscoped — so the code is the only place this can be enforced. Two locks: method: "GET" hard-coded with no method parameter, and an allowlist of the three read paths, so even a GET to a send-shaped URL is refused before it leaves. wassenger.test.ts fails the build if either lock is removed.

What this does not protect against, stated plainly: the key sits in the droplet's environment, and anyone with shell access can curl the API directly. The guard binds this codebase, not the credential. Without vendor-side scopes that gap cannot be closed — only noticed.

Amendment, 2026-08-21: approved is no longer the only way through

The gate as originally written refused any creative whose angle and copy were not approved. With 152 facts sitting in review that meant the system could not form a thought until somebody clicked, and a guardrail that stops the work is indistinguishable from a bug.

It was conflating two different things, now separated:

Claim State Buys you
a human signed this offapprovedpublication
every claim cites a re-checkable sourceprovisionalthe right to work

provisional is granted by the database, from citations — never by assertion. The trigger refuses the promotion without one, and a citation lacking both a URL and a dossier statement is refused by a check constraint. So loosening the gate required tightening a different one; otherwise it would have been a loophole rather than a design.

The point the original gate was protecting — never ship a claim nothing backs — is intact, and now enforced where the risk actually is: the point of spend, not the point of thinking.

See marketing-levels.md and 0043_verifiable_evidence.sql.