models:
- name: DigestDigest
collection: digest_digests
description: "DigestDigest (Odoo digest.digest) — one periodic KPI email configuration: WHO receives it (subscription rows), HOW OFTEN (periodicity + the slowdown ladder), WHICH registry KPIs are enabled (digest_digest_kpis rows), and the due-date spine the daily pull cron drains. Tenant-agnostic (ADR-0029): the composing service's tenancy decorator scopes the rows, not the module."
fields:
id:
type: uuid
attributes: ["@id", "@default(uuid)"]
description: "Unique record id"
name:
type: string
attributes: ["@required", "@max(255)"]
description: "Display name (the record's name field)."
periodicity:
type: DigestPeriodicity
attributes: ["@required", "@default(daily)"]
description: "Send cadence — hand-set; drives the next_run_date delta (1d/1w/1m/3mo) AND the slowdown ladder's login-recency window (daily 2d / weekly 7d / monthly 1m / quarterly 3m). Changing it resets next_run_date."
next_run_date:
type: date?
description: "THE due-date spine (DG-13): the cron's queue is exactly `next_run_date <= today AND state = activated` — no self-arming trigger exists. Nullable-with-meaning: NULL = never mailed yet. Advanced at the END of a successful send loop; a mail-delivery failure deliberately leaves it unadvanced so the digest retries whole next day."
state:
type: DigestState
attributes: ["@required", "@default(activated)"]
lifecycle:
shape: hand_set
state_machine: digest_state
display_labels:
activated: "Activated"
deactivated: "Deactivated"
description: "DG-2 — the 2-value hand-set machine: action_activate / action_deactivate are one-liner any->any verbs with no guards and no tracking; the cron's due scan filters on activated. Simplest machine in the family (EBT-1)."
metadata:
type: Metadata
attributes: ["@audit_metadata"]
description: "Audit metadata"
indexes:
- type: index
fields: [state, next_run_date]
description: "The cron's due scan: state = activated AND next_run_date <= today (the due-date column IS the queue — no other scheduler state exists)"
- name: DigestSubscription
collection: digest_subscriptions
description: "DigestSubscription (Odoo digest.digest.user_ids) — one recipient membership row per (digest, user), carrying the subscribed/unsubscribed state. The unsubscribe tombstone is what makes the RFC 8058 one-click endpoint idempotent: a second POST on an already-unsubscribed row is a bare 200 no-op, never an error and never a duplicate audit fact. user_id is a logical ref (no FK, no sapiens edge)."
fields:
id:
type: uuid
attributes: ["@id", "@default(uuid)"]
description: "Unique record id"
digest_id:
type: uuid
attributes: ["@foreign_key(DigestDigest.digest_digests)", "@indexed"]
description: "The digest (relation-driven FK, ON DELETE CASCADE)."
user_id:
type: uuid
attributes: ["@exclude_from_foreign_key_check"]
description: "The recipient # logical ref sapiens.User.id (no DB constraint). Internal users only — the internal-user predicate is host-injected through the fail-closed port at subscribe time."
state:
type: DigestSubscriptionState
attributes: ["@required", "@default(subscribed)"]
lifecycle:
shape: hand_set
state_machine: digest_subscription_state
display_labels:
subscribed: "Subscribed"
unsubscribed: "Unsubscribed"
description: "The membership state: unsubscribe (the RFC 8058 Tier A edge) flips subscribed -> unsubscribed and stamps unsubscribed_at; resubscribe flips back. Active recipients = state subscribed. Hand-set verbs only."
unsubscribed_at:
type: datetime?
description: "Stamped by the unsubscribe verb on the -> unsubscribed edge (NULL while subscribed) — the audit trail's when-arm; re-subscribe clears it."
metadata:
type: Metadata
attributes: ["@audit_metadata"]
description: "Audit metadata"
relations:
digest:
type: DigestDigest
attributes: ["@one", "@foreign_key(digest_id)", "@on_delete(cascade)"]
description: "The digest (real FK digest.digest_subscriptions.digest_id -> digest.digest_digests, cascade)"
indexes:
- type: unique
fields: [digest_id, user_id]
description: "Membership is a set — one row per (digest, user), ever; the tombstone reuses the row instead of minting a second"
- name: DigestDigestKpi
collection: digest_digest_kpis
description: "DigestDigestKpi (Odoo digest.digest kpi_* booleans) — one enabled-KPI row per (digest, registry key). The key namespace is the declarative KPI registry (kpi_<name>, registered by whichever module owns the metric); this row says THIS digest renders THAT metric. No FK to the registry exists (it is code) — the service layer validates keys against the composed registry, fail-closed on unknown names."
fields:
id:
type: uuid
attributes: ["@id", "@default(uuid)"]
description: "Unique record id"
digest_id:
type: uuid
attributes: ["@foreign_key(DigestDigest.digest_digests)", "@indexed"]
description: "The digest (relation-driven FK, ON DELETE CASCADE)."
kpi_key:
type: string
attributes: ["@required", "@max(120)"]
description: "The registry key, verbatim: kpi_<name> (e.g. kpi_res_users_connected). Must exist in the composed KpiRegistry at write time — unknown keys are refused loudly by the service verb; there is no DB-level vocabulary to enforce (the registry is code, not a table)."
metadata:
type: Metadata
attributes: ["@audit_metadata"]
description: "Audit metadata"
relations:
digest:
type: DigestDigest
attributes: ["@one", "@foreign_key(digest_id)", "@on_delete(cascade)"]
description: "The digest (real FK digest.digest_digest_kpis.digest_id -> digest.digest_digests, cascade)"
indexes:
- type: unique
fields: [digest_id, kpi_key]
description: "Enablement is a set — one row per (digest, registry key)"
enums:
- name: DigestPeriodicity
description: "Send cadence — drives both the next_run_date delta (1d/1w/1m/3mo) and the slowdown ladder's login-recency window; the ladder degrades along this order daily -> weekly -> monthly -> quarterly, monotonically, with no reverse rung"
variants:
- name: daily
description: "Every day (ladder window: a login within 2 days)"
default: true
- name: weekly
description: "Every week (ladder window: 7 days)"
- name: monthly
description: "Every month (ladder window: 1 month)"
- name: quarterly
description: "Every quarter (ladder window: 3 months — the ladder's floor: a quarterly digest never degrades further)"
- name: DigestState
description: "The 2-value hand-set machine (DG-2) — any->any one-liner actions, no guards, no tracking; the cron's due scan filters on activated"
variants:
- name: activated
description: "The digest is in the cron's due scan"
default: true
- name: deactivated
description: "Paused — the digest never mails until re-activated"
- name: DigestSubscriptionState
description: "The membership state machine — unsubscribe is the RFC 8058 Tier A edge (idempotent: a second POST on unsubscribed is a no-op); resubscribe is the declared reverse edge"
variants:
- name: subscribed
description: "The user receives this digest (an active recipient)"
default: true
- name: unsubscribed
description: "The tombstone — the user opted out; the row stays for idempotency and audit"