mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-21 16:46:26 +03:00
180 lines
11 KiB
Markdown
180 lines
11 KiB
Markdown
# Implementation contracts
|
|
|
|
Use for a build request. These are required behaviors for generated code, not
|
|
a claim that this skill contains a deployed service. Implement only the chosen
|
|
pattern. Default interest capture does not need a ranking engine or inventory.
|
|
|
|
## Public API
|
|
|
|
Use same-origin `/api/v1/` routes with bounded JSON bodies (start at 16 KB),
|
|
`Cache-Control: no-store`, and server-side validation. Unknown fields fail
|
|
validation; clients cannot set proof status, priority, consent timestamps, or
|
|
CRM IDs. Set allowed methods explicitly. Provider secrets stay server-side.
|
|
|
|
| Route | Request and authorization | Result |
|
|
| --- | --- | --- |
|
|
| `POST /entries` | Program, email, optional phone, explicit consent choices, attribution, challenge token, idempotency key | `202` generic acknowledgement after entry and outbox commit |
|
|
| `POST /verification/start` | Opaque flow session, channel, CSRF token; destination is read from the pending entry | `202` with safe pending state; resend is rate limited |
|
|
| `POST /verification/check` | Flow session, local attempt ID, code or provider token, CSRF token | `200` on accepted proof; neutral `422` invalid/expired |
|
|
| `GET /status` | Opaque flow cookie; no lookup by email or guessable entry ID | Only this flow's permitted status; no stored PII before proof |
|
|
| `POST /contact/change` | Flow ownership plus fresh proof for an existing verified entry; CSRF | Invalidate old channel attempts; new destination is unverified |
|
|
| `POST /leave` | Scoped authenticated session or one-purpose leave proof; CSRF | Idempotent local suppression and outbound suppression intent |
|
|
| `POST /webhooks/{provider}` | Provider signature over the required raw payload; timestamp/replay policy | Acknowledge only after durable event receipt |
|
|
| Worker endpoint | Scheduler authentication; no browser access | Bounded leased batch; safe on duplicate invocation |
|
|
|
|
Error shape: `{ "error": { "code": "try_again", "message": "Please try again shortly." }, "request_id": "opaque" }`.
|
|
Use `400` malformed request, `403` failed authorization/origin, `413` oversize,
|
|
`422` input validation, `429` throttled with `Retry-After`, and `503` unavailable
|
|
before commit. Never report success for a failed database commit. After commit,
|
|
provider delay does not undo registration; expose pending work honestly.
|
|
|
|
Persist the idempotency key scoped to program and operation with a canonical
|
|
request digest and safe response. An identical retry reuses the result;
|
|
reusing a key with different input returns `409`. Default retention: 24 hours,
|
|
after which database uniqueness still prevents duplicate entries. A duplicate
|
|
email must not grant control over the original entry. Issue an unprivileged
|
|
new flow and require channel proof before linking it to the existing record.
|
|
|
|
## Persistence and transactions
|
|
|
|
Use versioned SQL migrations and UTC database timestamps. Minimum tables:
|
|
|
|
- `programs`: immutable ID, promise, policy version, configuration revision.
|
|
- `entries`: opaque ID, program, lifecycle status, immutable arrival sequence,
|
|
row version, timestamps. Uniqueness scope is one program, not the whole CRM.
|
|
- `contacts`: entry, channel, original protected value, canonical lookup value,
|
|
normalization version, verification version and time, suppression status.
|
|
- `proof_attempts`: local ID, entry/channel/destination version, provider
|
|
reference, method, expiry, status, attempt counters. Never retain OTPs.
|
|
- `consents`: purpose, channel, decision, disclosure version, event time,
|
|
collection source; withdrawal is an event, not deletion of history.
|
|
- `outbox`: event ID, aggregate/version, operation, mapping revision,
|
|
available time, lease expiry, attempts, status, redacted last error.
|
|
- `crm_links`: adapter/tenant/entry unique key, provider ID, last synced version.
|
|
- `audit_events` and `webhook_receipts`: unique event IDs, minimal metadata.
|
|
|
|
Commit entry + contact + consent + outbound intent together. Unique constraints
|
|
handle concurrent duplicates. A proof completion uses compare-and-set on the
|
|
attempt and destination version, updates the contact, reevaluates eligibility,
|
|
and inserts the CRM intent in one transaction. External network calls happen
|
|
outside database transactions; lease outbox rows using `FOR UPDATE SKIP LOCKED`,
|
|
commit the lease, then call the provider. Expired leases are recoverable.
|
|
|
|
Email normalization: trim surrounding whitespace, normalize the domain with a
|
|
tested IDNA parser, preserve local-part spelling; document any case-folding
|
|
rule. Do not strip dots or plus tags globally. Normalize phones using a tested
|
|
library and explicit country context to E.164. Syntax is not reachability.
|
|
Do not merge people merely because they share a phone number.
|
|
|
|
Default to managed storage encryption, TLS, and least-privilege database roles.
|
|
If field encryption/keyed lookup hashes are introduced, retain a key ID and
|
|
normalization version, plan dual-read/dual-write rotation and uniqueness during
|
|
migration, and keep keys outside Postgres. A bare hash of a phone or email is
|
|
not anonymous. Expire unconfirmed records after 7 days by default; confirmed
|
|
retention is a campaign decision captured before launch. Deletion covers
|
|
providers, backups under their retention schedule, exports, and queued payloads.
|
|
|
|
## Managed proof binding
|
|
|
|
The application owns membership and eligibility. The provider owns challenge
|
|
issuance and checking. Store its challenge/method identifier with the exact
|
|
entry, channel, destination version, and program. Never accept a client-supplied
|
|
provider user ID or a generic provider session as proof of both contacts.
|
|
|
|
On check, call the provider from the server; verify the returned approved
|
|
channel and destination match the pending attempt. Recheck attempt/version
|
|
under a database lock before recording success. A contact edit or superseding
|
|
attempt must win over an old in-flight response. If the provider accepted a
|
|
proof but the database write failed, reconcile using a supported provider status
|
|
lookup; otherwise issue a new challenge. Never invent success from a timeout.
|
|
|
|
Issue an opaque flow cookie (`Secure`, `HttpOnly`, `SameSite=Lax`) and store its
|
|
hash server-side. Check Origin against the configured canonical origin plus
|
|
CSRF on browser mutations. A cross-device magic link creates a limited recovery
|
|
flow, not an admin session; explicit confirmation performs the server exchange.
|
|
Use a canonical configured callback, never a host-header-derived or arbitrary
|
|
`next` URL. Provider callback parameters are untrusted until checked.
|
|
|
|
Codes: let the provider generate, expire, and check them. Use the stricter of
|
|
provider limits and local caps; start with a 60-second resend cooldown, 3 sends
|
|
per destination per hour, 5 local failed checks per attempt, and configured
|
|
country and account spend limits. Limits are atomic shared state, not process
|
|
memory. Provider resend may reuse a code; invalidating a local attempt does not
|
|
necessarily revoke the provider's code. Test the actual provider semantics.
|
|
|
|
Magic-link extension: retain provider scanner protection. The callback loads
|
|
no analytics/media and uses no-store/no-referrer headers. Remove tokens from
|
|
browser history after secure transfer; redact them at proxy and app log layers.
|
|
GET must not activate membership or CRM delivery. For custom SMS links use
|
|
at least 32 random bytes, hash at rest, a short expiry, scoped purpose, atomic
|
|
single use, and an explicit confirmation POST. Link text never contains PII.
|
|
|
|
## CRM adapter contract
|
|
|
|
Configure the provider through a server-side registry of supported adapters;
|
|
runtime configuration selects an adapter, not arbitrary downloaded code. A new
|
|
CRM requires an implemented and tested adapter. Validate tenant, credentials,
|
|
field types, scopes, configured API version, allowed domains, and unique key
|
|
before enabling delivery. Never accept a CRM URL or credentials from public forms.
|
|
|
|
The adapter supplies `validateConfig`, `upsertContact`, `suppressContact`,
|
|
`deleteContact`, and `reconcileContact`. Each operation returns provider ID,
|
|
applied version, or a typed retryable/permanent/authentication error plus optional
|
|
retry-after. Keep provider SDK models outside the domain layer.
|
|
|
|
Canonical intent:
|
|
|
|
```json
|
|
{
|
|
"event_id": "opaque-event-id",
|
|
"operation": "upsert",
|
|
"program_id": "launch-autumn",
|
|
"entry_id": "opaque-entry-id",
|
|
"entry_version": 8,
|
|
"mapping_version": 2,
|
|
"adapter": "hubspot",
|
|
"tenant_ref": "server-config-reference"
|
|
}
|
|
```
|
|
|
|
Build the contact payload from current eligible state immediately before send.
|
|
For each channel require its own proof and purpose permission; do not include an
|
|
unverified optional phone just because email was confirmed. Marketing enrollment
|
|
is a separate provider operation from storing a contact; default it off unless
|
|
explicitly selected. CRM-not-connected means hold no external writes, display
|
|
connection status, and backfill eligible current records when connected.
|
|
|
|
Use program + entry as the external identity, adding provider/tenant scope.
|
|
Do not treat an event idempotency key as a provider uniqueness constraint. Verify
|
|
the chosen CRM supports a unique external property/upsert, or implement lookup,
|
|
serialization and reconciliation. A timeout after an unknown outcome triggers
|
|
lookup before another create. If neither is possible, mark uncertain and require
|
|
reconciliation rather than creating duplicates. Email-only upsert is unsuitable
|
|
for phone-only programs and can merge unrelated records across campaigns.
|
|
|
|
Serialize effects per entry. Version 8 cannot overwrite version 9. Supersede
|
|
stale queued upserts; prioritize suppression/deletion and recheck eligibility
|
|
before sends. If withdrawal races with an in-flight upsert, enqueue and verify
|
|
the compensating suppression. CRM edits to unrelated sales fields are preserved;
|
|
local consent controls cannot be broadened by CRM edits. Receive supported CRM
|
|
opt-outs as suppression events and reconcile missed webhooks.
|
|
|
|
Retry `429`, network timeouts and `5xx` with jittered exponential backoff,
|
|
honoring a longer provider `Retry-After`; start at 5 seconds, cap ordinary delay
|
|
at 1 hour, dead-letter after 24 hours. Pause authentication failures pending
|
|
credential repair; dead-letter invalid mappings with redacted diagnostics.
|
|
Batch adapters must classify each record independently. No promise of exactly
|
|
once delivery: use at-least-once work with idempotent effects and reconciliation.
|
|
|
|
## Fairness extensions
|
|
|
|
Interest capture has no rank. FIFO uses a database-assigned immutable sequence
|
|
and deterministic tie-break, not browser time. PostgreSQL sequence gaps are
|
|
allowed; document assignment order rather than claiming strict commit order.
|
|
Referral score is derived from distinct eligible conversions with a cap and
|
|
stable sequence tie-break. Publish whether points change priority or merely
|
|
eligibility; do not promise a fixed number of places when other scores change.
|
|
Policy revisions do not silently mutate past promises. Store the policy and
|
|
inputs used for each invitation. Lotteries need a frozen eligible population,
|
|
documented random selection, draw evidence, and separate allocation authority.
|