Skip to content

Add FEAT-013 and FEAT-014 tickets, update feature matrix - #3

Merged
agreenspan merged 5 commits into
mainfrom
claude/document-encryption-agent-features-8byZG
Mar 3, 2026
Merged

Add FEAT-013 and FEAT-014 tickets, update feature matrix#3
agreenspan merged 5 commits into
mainfrom
claude/document-encryption-agent-features-8byZG

Conversation

@agreenspan

Copy link
Copy Markdown
Contributor

Summary

This PR adds two new feature tickets to the backlog and updates the feature matrix to reflect the template's encryption capabilities and upcoming AI developer experience features.

Changes

  • Added FEAT-013: Encryption (tickets/FEAT-013-encryption.md)

    • Documents the existing AES-256-GCM field-level encryption system already built into the template
    • Identifies critical gap: key escrow/backup mechanism to prevent data loss from accidental key deletion
    • Outlines key lifecycle management, encryption status visibility, and feature marketing tasks
    • Includes risk scenario and escrow strategy options (encrypted file backup, Shamir's Secret Sharing, HSM, etc.)
  • Added FEAT-014: AI Developer Experience (tickets/FEAT-014-ai-developer-experience.md)

    • Proposes making the template "AI-native" as a first-class feature
    • Outlines four core components: skills (Claude Code slash commands), rules files (for Claude/Cursor/Windsurf/Copilot), agent configurations, and MCP servers
    • Includes 8 pre-built skills for common workflows (db-migrate, add-module, security-check, etc.)
    • Recommends phased MVP: rules files → skills → agents → MCP servers
    • References internal prompt refinement harness (~40% built) as validation/optimization layer for AI DX features
  • Updated FEATURES.md

    • Reorganized encryption section to highlight field-level encryption engine as a headline feature
    • Added detailed list of encryption capabilities (registry pattern, auto-discovery, type-safe helpers, CI validation, etc.)
    • Marked key escrow/lifecycle and encryption admin dashboard as in-progress (FEAT-013)
    • Added AI Developer Experience as upcoming feature (FEAT-014)
    • Updated security highlights to include field-level encryption
  • Updated kanban-backlog.md

    • Added FEAT-013 and FEAT-014 to backlog
    • Created new "Security & Encryption" and "AI Developer Experience" sections
    • Updated total backlog count from 14 to 16 items
    • Updated last modified date to 2026-03-02
  • Updated docs/claude/ENCRYPTION.md

    • Minor formatting improvements (em-dashes, consistency)

Implementation Details

Both tickets are comprehensive design documents with:

  • Clear objectives and success criteria
  • Detailed task breakdowns with checkboxes
  • Open questions for design decisions
  • Related ticket cross-references
  • Implementation notes and architecture context

FEAT-013 focuses on a critical security gap (key backup/recovery) in an otherwise complete encryption system. FEAT-014 positions the template as differentiated by shipping AI context alongside code, with skills, rules, and agents that make AI assistants immediately productive in template-based projects.

https://claude.ai/code/session_01SJUWtDFbGpQW2M6j4JXSmm

claude added 5 commits March 2, 2026 22:31
…kets

- FEAT-013: Document encryption module as first-class feature, add key
  escrow/backup tasks to prevent catastrophic data loss from key deletion
- FEAT-014: AI-native developer experience — skills, rules, agents, MCP
  servers so AI coding assistants can immediately work in the template
- Enhance FEATURES.md encryption section to reflect full module capabilities
  (registry pattern, auto-rotation, CI validation, singleton locking)
- Update backlog kanban board with both new tickets

https://claude.ai/code/session_01SJUWtDFbGpQW2M6j4JXSmm
Add context about the Delphi prompt refinement harness as the future
validation/optimization layer for AI DX features. Notes ~40% MVP exists
internally, design doc complete, potential rebuild on the template.

https://claude.ai/code/session_01SJUWtDFbGpQW2M6j4JXSmm
Remove Delphi branding to avoid naming collision with internal project.
Use generic "Prompt Refinement Harness" label. Added more architecture
detail from the design doc (scoring model, perturbation, MVP phases).

https://claude.ai/code/session_01SJUWtDFbGpQW2M6j4JXSmm
@vercel

vercel Bot commented Mar 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
template-admin Ready Ready Preview, Comment Mar 3, 2026 0:47am
template-superadmin Ready Ready Preview, Comment Mar 3, 2026 0:47am
template-web Ready Ready Preview, Comment Mar 3, 2026 0:47am

Request Review

@agreenspan
agreenspan merged commit 345ac13 into main Mar 3, 2026
4 checks passed
@agreenspan
agreenspan deleted the claude/document-encryption-agent-features-8byZG branch March 3, 2026 00:47
agreenspan pushed a commit that referenced this pull request Jun 14, 2026
- Marker reader handles non-object Conditions (bare `true`/`false` are valid
  Conditions) and tolerates whitespace around the value — previously these
  returned null and, in nested position, corrupted the enclosing block's depth
  tracking and dropped the outer body. (review #2, #3)
- Malformed markers advance by the token length, not one char, so a bad marker
  can't be mis-scanned into a false {{else}} split. (#4)
- validateConditions flags dead branches (content after a bare {{else}};
  multiple {{else}}) and keeps validating later blocks past an unterminated one
  instead of bailing. (#5, #6)
- Rename the branch-kind discriminant 'elseif' -> 'elseIf' (camelCase).

Not changed: json-rules `check` returns a string as the mismatch *reason* (not an
error), so `=== true` is correct — the review's #1 was a misdiagnosis, caught by
the existing tests. +tests for booleans / whitespace / dead-branch / unterminated.
Email suite 80/0 (env-injected), typecheck + biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
arongreenspan added a commit that referenced this pull request Jun 14, 2026
…#55)

* feat(email): if/else-if/else conditionals + save-time rule validation

Conditionals were single-block (`{{#if rule=<Condition>}}…{{/if}}`) via a flat
first-`{{/if}}` scan. Replace with a depth-aware block parser (conditionParser.ts)
that supports `{{else if rule=<Condition>}}`, `{{else}}`, and correct nesting —
each branch's body is re-evaluated, so nested blocks work and a nested block's
else/else-if can't leak into the outer one. Render the first matching branch;
bare `{{else}}` is the fallback. Malformed/uncheckable rules keep the prior
behavior (surfaced with the body in local/test, fail-closed in prod).

Validate conditions structurally with json-rules' `validateRule` on BOTH ends,
since the data surface upstream can change between authoring and send:
- save: `assertValidConditions(mjml)` in `saveEmailTemplate` fails fast on broken
  rules (ConditionValidationError) instead of shipping a silent render-time bomb;
- render: the evaluator runs `check` per branch and surfaces malformed rules.

Lens-aware validation (is the field actually exposed to this email?) is
deliberately deferred until the rules-builder lands. +13 tests (else/else-if/
nesting/regression + validator); existing interpolate tests still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(email): harden conditional parser per adversarial review

- Marker reader handles non-object Conditions (bare `true`/`false` are valid
  Conditions) and tolerates whitespace around the value — previously these
  returned null and, in nested position, corrupted the enclosing block's depth
  tracking and dropped the outer body. (review #2, #3)
- Malformed markers advance by the token length, not one char, so a bad marker
  can't be mis-scanned into a false {{else}} split. (#4)
- validateConditions flags dead branches (content after a bare {{else}};
  multiple {{else}}) and keeps validating later blocks past an unterminated one
  instead of bailing. (#5, #6)
- Rename the branch-kind discriminant 'elseif' -> 'elseIf' (camelCase).

Not changed: json-rules `check` returns a string as the mismatch *reason* (not an
error), so `=== true` is correct — the review's #1 was a misdiagnosis, caught by
the existing tests. +tests for booleans / whitespace / dead-branch / unterminated.
Email suite 80/0 (env-injected), typecheck + biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(email): drop whitespace tolerance in conditional markers

Markers are machine-generated (agents + the rules-builder), so they're always
tight — `}}` immediately follows the value, matching the interpolation syntax
which already has no whitespace tolerance. Remove the `isSpace` helper + the
leading/trailing skip loops + `.trim()` from the marker reader; it just simplifies.
The non-object (bare `true`/`false`) handling stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Aron Greenspan <aron.greenspan@inixiative.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
agreenspan added a commit that referenced this pull request Jun 18, 2026
…+alert, signal on direct only

- #1 cross-process silent-drop: superseding lanes now write via upsert (last-writer-wins),
  not deleteMany+createMany(skipDuplicates) which kept first-commit and dropped the latest.
  Plain fan-out rows still createMany. Both still one batched txn.
- #2 closing latch: flushOutbox resets closing in finally + re-arms mid-drain stragglers.
- #5 tripIfFull probes fresh depth (cached read tripped the flag late → overshoot).
- #6 supersede signal moved to the direct path only (spill path supersedes at drain).
- Overflow flag is now a start-timestamp with a drain-renewed TTL (survives between ticks,
  self-clears if the drain dies) + a stuck-overflow alert when it won't clear.
- #3 (drain holds mutex across the add loop) kept by design — bounded, self-recovering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agreenspan added a commit that referenced this pull request Jun 22, 2026
…obs overflow buffer (#62)

* ZLT-3008: email audience/recipient resolution + send→deliver fan-out

- add lib/audience.ts: channel-agnostic resolveAudience(Audience[]) -> Recipient{user,context}, dedup by (user, context)
- move recipient targeting out of @template/email (pure composition) to the resolution layer
- split monolithic sendEmail into planner (sendEmail) + per-recipient deliverEmail (idempotency-keyed)
- compose once per context with the render-error policy; fan out one deliverEmail per recipient
- cc/bcc are resolved roles, single-message-only (warn + drop on fan-out)

Reference implementation for ZLT-3008.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: reshape — audience carries disposition, deliver owns settle

Addresses review feedback on the send→deliver pipeline:

- to/cc/bcc are all `Audience` now. `Audience` carries `as?: 'to'|'cc'|'bcc'`
  and `resolveAudience` partitions them into disposition-tagged `Recipient`s
  (dedup by (user,context); strongest disposition wins on collision). One
  `audience: Audience[]` input replaces the three parallel arrays.
- `data` everywhere, never `variables` — matches the appEvent payload key.
- The planner routes the template *name* + context, not pre-composed
  subject/mjml strings. deliverEmail owns the whole resolve-and-render step.
- `settleTemplate` (compose + owner-cascade fallback + render-error policy)
  pulled out of the handler into lib/emailTemplate.ts so the deliver job
  calls it — fallback is no longer trapped inside the planner.
- from-address resolves in deliverEmail (per-context), not the planner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: rebuild email pipeline on generic lens primitives; drop audience

db primitives (@template/db, channel-agnostic):
- fetchLens: lens → rows (toPrisma → requireWhere fail-closed → include → check)
- includeFromLens: Prisma include from a lens's declared relations (projectByPath,
  not exposedSurface — respects pick/omit, no off-path re-exposure or explosion)
- prune: project row/row[] to the lens's available surface
- requireWhere: fail-closed guard against an empty where

email pipeline (apps/api):
- EmailHandoff slimmed to { template, data } — emitters emit references only
- sendEmail: registry → fetchLens(source) → senders → recipients(source, sender)
  → prune → fan out (channel-agnostic resolution)
- deliverEmail: settleTemplate @ sender → interpolate → render → send (email-specific)
- registry (lib/email/registry.ts): per-template { source, senders, recipients, data? };
  data defaults to the pruned source
- removed audience.ts + audienceLens.ts; ReachContext + contextKey now in lib/email

inquiry-invite-organization-user (renamed from org-invitation):
- data is the inquiry (source) projected — sourceOrganization included, no sourceUser
- button composed in the template from {{sender.webUrl}}/invitations/{{data.id}}

render: interpolate now resolves nested paths ({{data.sourceOrganization.name}}) via lodash get

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: rename verification data to verificationUrl; inline its button

Name the data ref by what it does (verification-url), not the UI element
that happens to render it. The email-verification button moves inline like
inquiry-invite did, dropping the now-orphaned generic system-button component.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: two-layer email idempotency + per-recipient cc/bcc

Idempotency keyed on the logical event, not the random job id:
- planner (sendEmail) id  = {eventName}:{template}:{hash(event.data)}
- deliver (deliverEmail)  = {eventName}:{template}:{contextKey(sender)}:{to}:{hash(contents)}
Shared ordered prefix, hash last. A duplicate emit collapses at the planner;
a re-fanned send collapses at delivery; a changed payload re-sends. The bridge
threads event.name + sets the planner id; minimal refs in event.data make the
anchor stable. New stableHash util (safe-stable-stringify + sha256).

cc/bcc return as optional per-recipient lenses on the registry entry, resolved
inside the fan-out loop (manager-cc pattern) and passed through to the client.

Tests: stableHash (5, run green), idempotency key shape (pure, logic verified),
sendEmail fan-out + cc resolution (integration, needs the test DB to run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: jobs overflow buffer design + INFRA-021 ticket; makeSingletonJob → createLock

- Design doc for a durable overflow buffer (transactional outbox + leaky-bucket
  drain) in front of BullMQ at the enqueueJob chokepoint — bounds Redis depth on
  large fan-outs while keeping per-job idempotent retries (no chunking).
- INFRA-021 ticket + kanban-aron entry.
- Fold makeSingletonJob onto createLock (fenced compare-and-delete release) instead
  of the bespoke SET NX + unconditional del.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: implement jobs overflow buffer (JobOutbox + drain cron)

- JobOutbox model: jobId unique, dedupeKey indexed, data/options Json.
- outbox.ts: global overflow flag + cached fleet-global depth probe; coalescing
  accumulator (size|time trigger, serialized flushes, resolve-on-commit);
  spillToOutbox (superseding → deleteMany+create; fan-out → createMany skipDuplicates);
  flushOutbox for shutdown.
- enqueue.ts: adhoc-only guard, bypass option, flag check → spill, tripIfFull.
- drainOutbox handler: singleton (createLock), tops queue up to cap, re-enqueues
  with stored jobId, clears flag at low-water; 15s 6-field cron.
- Wire flushOutbox into worker shutdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: tests for jobs overflow buffer (spill + drain)

Exercises spillToOutbox/drainOutbox directly (enqueueJob short-circuits under isTest):
fan-out createMany coalescing, skipDuplicates dedup, superseding deleteMany-first
latest-wins, drain admit+clear+flag-clear, idempotent re-enqueue by stored jobId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: address adversarial review of the overflow buffer

- Correct the exactly-once claim → at-least-once: removeOnComplete evicts the jobId,
  so a drain crash-replay can double-run; handlers must be idempotent (doc + ticket fixed).
- API process now flushes the buffer on shutdown (was worker-only); flush runs after
  intake stops; flushOutbox awaits + observes + logs the in-flight flush (no swallowed failure).
- Drain: per-row try/catch (one poison row can't strand the batch); superseding rows
  re-signal + add without a fixed jobId, matching the direct path.
- Superseding spill is an atomic upsert on @@unique([handlerName, dedupeKey]) — race-safe.
- Depth probe counts waiting+active, not delayed (excludes scheduled cron repeats).
- Lazy-read config (cap/triggers) → cap-trip + partial-drain now testable; closing guard
  flushes inline during shutdown.
- Extract shouldSpill (pure) + move signalSupersededJobs to outbox.ts (breaks an import cycle).
- Tests: route table, cap-trip, partial drain, cross-flush dedup, superseding latest-wins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: unify spill path, share flush/drain mutex, durable shutdown

- Everything (fan-out + superseding) routes through ONE accumulator — no per-spill
  upsert (which round-trips the DB per superseding job and falls over under many at once).
  The flush dedups in a txn: latest-per-lane within the batch, deleteMany OR'd over the
  batch's lanes, then createMany(skipDuplicates).
- Flush and drain share one mutex (runOnOutboxQueue) so they never touch JobOutbox
  concurrently — no stale re-enqueue racing a lane delete.
- Durable shutdown: flushOutbox clears the timer, settles in-flight, loops to catch late
  arrivals, and retries each batch (bounded) before surfacing a failure loudly.
- Tests: cross-flush + within-batch superseding collapse; reset FLUSH_MAX_ROWS per test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: document tick-granular last-wins supersession contract (no registry)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: address review round 2 — upsert superseding, renewable flag+alert, signal on direct only

- #1 cross-process silent-drop: superseding lanes now write via upsert (last-writer-wins),
  not deleteMany+createMany(skipDuplicates) which kept first-commit and dropped the latest.
  Plain fan-out rows still createMany. Both still one batched txn.
- #2 closing latch: flushOutbox resets closing in finally + re-arms mid-drain stragglers.
- #5 tripIfFull probes fresh depth (cached read tripped the flag late → overshoot).
- #6 supersede signal moved to the direct path only (spill path supersedes at drain).
- Overflow flag is now a start-timestamp with a drain-renewed TTL (survives between ticks,
  self-clears if the drain dies) + a stuck-overflow alert when it won't clear.
- #3 (drain holds mutex across the add loop) kept by design — bounded, self-recovering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: address review round 3 — flag TTL renew up-front, drop shutdown re-arm timer

- Drain renews the overflow flag at the START of the tick too, so a long admit pass can't
  let the TTL lapse mid-tick (which would briefly drop the flag + reset the stuck-alert age).
- flushOutbox no longer re-arms a setTimeout in its finally: during a real shutdown that timer
  could fire after Redis/DB are torn down and lose the job. The drain loop already persists
  everything present; an aborted shutdown re-arms on its next spill.
- Pre-existing follow-up (not fixed): signalSupersededJobs scans the full queue per call, now
  per-row in the drain loop — batch the scan per drain pass if superseding volume grows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: heartbeat-renew the overflow flag for the whole drain pass

withOverflowRenew wraps the drain with a recursive setTimeout that renews the flag's TTL
(createLock heartbeat pattern), with a final renew + clearTimeout in the finally. This holds
the flag open for an arbitrarily long admit loop instead of relying on TTL > tick duration —
fully closing the round-3 'flag lapses mid-long-drain' finding. EXPIRE no-ops on an absent
key, so the final renew never resurrects a flag the pass cleared.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: renew interval = a fraction of the TTL (drop the bad floor)

renewMs = floor(TTL/3) — a fraction is always below the TTL by construction. The previous
Math.max(1000, …) floor could exceed the TTL for small TTLs (== TTL at the 1s minimum), so
the heartbeat would fire at/after expiry and the flag could lapse. No TTL clamp needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: batch the supersede scan — one queue scan per drain pass, not per row

signalSupersededLanes(Set) does a single getJobs scan for all the batch's superseding lanes;
signalSupersededJobs(dedupeKey) delegates to it for the direct path. The drain collects its
lanes and signals once, removing the O(rows × queueSize) scan under the mutex. No scan at all
when a batch has no superseding rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: resolveSender seam (stub) + drop address from template path

- add resolveSender(ReachContext) returning the platform env fallback; deliverEmail resolves {{sender.*}} through it. Real per-sender/DKIM address resolution is deferred to its own ticket behind this seam.
- remove all postal-address references from the email template path (sender.address footer token + senderVars).
- alias escape->escapeHtml in interpolate to satisfy noShadowRestrictedNames.
- biome auto-format (line-wrapping) across touched job/lens/cache files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: email comms — Sender, CommunicationLog, deliverability, unsubscribe, template versioning

- Sender DU + scope-first cascade; planner/deliver split with find-or-create fence
- CommunicationLog per-recipient ledger + at-most-once idempotency; CAS-guarded lifecycle
- Auto email Contact + canDeliver gating; bouncer pre-flight cached on Contact
- HMAC unsubscribe capability + RFC 8058 one-click endpoint + List-Unsubscribe headers
- Template/component versioning: audit-log snapshot graph (emailComponentAuditLogIds) + backprop walk; deliver pins emailTemplateAuditLogId; recompose reader; seeds audited via apps/api seed wrapper
- Docs (COMMUNICATIONS.md); tickets COMM-003/004/005

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: rename canTarget → inScope (gate ① scope predicate)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: mark gate ① inScope @wip (scope resolution is COMM-005)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: COMM-005 — decided direction for inScope (permission check → Prisma push-down + per-candidate loop for cross-source lenses)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: COMM-005 — narrow-then-evaluate (Prisma where narrows, then predicate loop is cheap on the small set)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: adversarial fixes — versioning hook guards + seed wrapper diagnostics

- emailVersioning: skip no-op updates (no audit snapshot written → don't rewrite an older
  immutable snapshot's pins); handle soft-delete (deletedAt via update) by re-pinning ancestors
  without stamping the tombstone
- seed wrapper: log the seed error instead of swallowing it
- COMM-006: ticket the open versioning/infra items (send-pin fidelity, async backprop fan-out,
  recompose guards, lazy queue/Redis init)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: drift-proof send pin + versioning review follow-ups

- Pin the template's latest snapshot in the template lookup itself (auditLogs take:1 desc),
  surfaced through compose → settle → deliver, so render and pin are one read (no drift). Drops
  deliverEmail's separate findFirst.
- Tests: no-op re-save rewrites nothing; latest snapshot recomposes to exactly the live
  composition before and after an edit (no-drift invariant).
- Seed wrapper fails loud (no try/catch).
- COMM-006: V2 resolved (one-read pin), V4 acknowledged intended; notes for version restore
  (cross-ownership / degraded list) and component-scoped degrade + error attribution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: collapse PLATFORM_NAME onto PROJECT_NAME; document JOBS_* env knobs

resolveSender reads PROJECT_NAME (the init-script project-name key) instead of
the parallel PLATFORM_NAME. Document the overflow-buffer tuning knobs in
apps/api/.env.local.example with their in-code defaults.

* ZLT-3008: order emailVersioning snapshot lookups by id (uuidv7) not createdAt

createdAt is identical for all audit rows written in one transaction (Postgres
now() is the txn clock), so findFirst/findMany ordered by createdAt picks an
arbitrary row on a multi-write txn and can pin a stale snapshot. uuidv7 id is
monotonic, so ordering by id desc is the correct latest-first order.

* ZLT-3008: guard interpolate against prototype-chain/function paths; mark recompose @wip

Template authors control template text, so {{data.constructor.name}} / {{data.__proto__}}
walked the prototype chain via lodash get, and {{data.toString}} resolved an inherited
function. Reject __proto__/prototype/constructor path segments and skip function values.
recomposeCommunication is an intentional @wip seam (resend/preview consumer is a COMM
follow-up), marked so it isn't mistaken for dead code.

* ZLT-3008: lock the user's login-email contact against manage (update/delete)

The email Contact mirroring the user's login (valueKey === user.email) must stay
in sync with auth and never be edited directly. AND a json-rules notEquals rule
(field valueKey, path user.email) into contact.manage — the action both update and
delete gate on. hydrate() loads the owner user, so the path resolves in-memory; the
rule is a no-op for every other contact (phone/org/space/second-email all differ).
A server-side email-change flow updates the contact outside the permix gate.

* ZLT-3008: includeFromLens also hydrates relations referenced by the where

fetchLens re-checks the full condition in memory after toPrisma; with only the
projection hydrated, a where filtering on an un-projected relation left that
relation absent, so the check threw/dropped. Walk the lens whereClauses (fields,
field-to-field `path` ValueSource, aggregate/array sub-conditions, all/any/if),
resolve the leading `object`-relation run via the lens field map, and merge into
the projection include. prune still strips these from output, so consumers are
unchanged; scalar wheres (all current usage) produce the same include as before.

* ZLT-3008: index CommunicationLog FKs; cover overflow shutdown-drain + commit-reject

CommunicationLog: index every FK column (the onDelete: SetNull cascades and
recompose/lookup paths scan them). Adds tests for flushOutbox persisting rows
buffered at shutdown, and spillToOutbox rejecting when the commit fails
(resolve-on-commit, not on accumulation) — both run green against the test DB.

* ZLT-3008: reconcile jobs-overflow-buffer design doc with shipped schema

Doc was the stale side of the drift @stevenolay flagged: §9 drain pseudocode
counted waiting+delayed (code counts waiting+active), and §12's schema sketch
showed collapseKey + @@index([id]) + a dedupeKey ?? options.id semantic that
shipped as dedupeKey, no separate index (uuidv7 PK is the FIFO order). Align the
sketch to packages/db/prisma/schema/jobOutbox.prisma and mark status Implemented.

* ZLT-3008: add §0.5 Verification Discipline to the agent entrypoint

verify-before-assert (a relayed/subagent claim is unverified until reproduced),
no-deferral-as-escape-hatch, exhaust-validation-before-caveating.

* ZLT-3008: user-actor email ownership (two-chain cascade) + versioning cleanups

Owner model:
- EmailTemplate/EmailComponent gain a userId owner FK + User/OrganizationUser/SpaceUser
  tiers (EmailOwnerModel), denormalized parent FKs, per-tier partial uniques, and registry
  entries mirroring Token. AuditLog.contextUserId records owner provenance on snapshots.

Cascade (two separate chains, converging at default):
- user: SpaceUser -> OrganizationUser -> User -> default
- org:  Space -> Organization -> default
- ownerScope(sender) maps each sender to its own tier; explicit platform -> default bridge.
- lookupTemplate/lookupCascade/lookup + parentOwner walk the chains.

Versioning hook review cleanups:
- one shared VersionedRecord type (Pick of the Prisma models, no redeclare)
- inline subjectWhere, drop all selects, isEqual instead of sameIds
- drop dead hard-delete branch (preventHardDelete already enforces soft-delete)
- userId flows through resolveChildAuditLogIds + the snapshot owner context.

Upsert as the idempotent pattern:
- add factory.upsert; userEmailContact + the contact login-email test now upsert
- Contact's redundant partial uniques -> plain uniques (valid ON CONFLICT targets;
  NULLS DISTINCT already left the null-FK branch unconstrained).

Also: seed is its own atlas concept (infrastructure:seed); COMM-003 reconciled to the
built two-chain design (no longer deferred) + COMM-007 tracks app-wide orgId denormalization;
COMMUNICATIONS.md cascade docs updated; repo biome formatting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: component evacuation + slug-keyed version map + degraded index projection

Evacuation:
- cascade lookups filter deletedAt:null; per-owner email uniques are deletedAt-partial.
  Soft-deleting an override now fails over to the parent tier AND frees the slug slot to recreate.

Version map (supersedes flat emailComponentAuditLogIds String[]):
- AuditLog.componentVersions Json = { slug: auditLogId | null } — the version's resolved dep
  tree, null = ref unresolved (broken). recompose follows it slug-keyed (no brace-naive regex).
- resolveChildAuditLogIds -> resolveComponentVersions (returns the map).

Degraded index projection:
- degradedComponentRefs String[] on EmailTemplate/EmailComponent — the null-keyed slugs, an
  audit-ignored live projection (ignoreFields) maintained by the versioning hook, for the
  index-list 'which have errors'. Render-error attribution stays a send-time CommunicationLog concern.

Upsert as the idempotent pattern:
- factory.upsert; userEmailContact upserts; Contact per-owner uniques made non-partial
  (partial uniques aren't valid ON CONFLICT targets -> Postgres 42P10).

Versioning hook stays '*' (must run after the global audit hook it stamps; model hooks run
before global hooks, so per-model registration would run before audit and breaks it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: rename snapshotChildVersions + distinct latest-snapshot lookup

- stampOwnChildIds -> snapshotChildVersions (augments the audit snapshot with the child-version map; comment reworded off 'stamp').
- resolveComponentVersions: distinct ['subjectEmailComponentId'] + id desc so Postgres returns the latest snapshot per child (one row each) instead of loading the full version history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: batch sendEmail planner (kill N+1) + COMM-008 + enum-after-model

- sendEmail: replace the per-recipient contact.findFirst + communicationLog.upsert loop with
  batched lookups — one contact.findMany -> userId dict, one communicationLog.createManyAndReturn
  (skipDuplicates, idempotent on idempotencyKey) + read-back for ids. cc/bcc left per-recipient
  (speculative). Removes ~3N round-trips per fan-out.
- COMM-008: ticket the email-change gap (login email is creation-time-only; re-sync the login
  contact via a User-update hook / background job if/when we support changing it).
- communicationLog.prisma: enums moved below the model (convention).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: split outbox.ts into atomic outbox/ files

outbox.ts (264 lines) -> outbox/ folder, one concern per file:
- config.ts      env knobs + constants + FLAG_KEY
- types.ts       OutboxRow + toCreateInput + shouldSpill
- queueDepth.ts  the cached depth probe
- flag.ts        overflow flag lifecycle (is/set/renew/clear/tripIfFull/warnIfOverflowStuck/withOverflowRenew)
- mutex.ts       the shared serialized queue (runOnOutboxQueue) — used by accumulator AND drain
- supersede.ts   signalSupersededLanes/Jobs
- accumulator.ts the write-behind buffer (accumulate/flush/writeBatch/dedupeLatestPerLane) + shutdown flushOutbox
- index.ts       barrel — keeps the '#/jobs/outbox' public surface identical

No behavior change (820/820, typecheck green, atlas-clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: TODO documenting the hook-ordering dependency requirement

executeHooks ordering is implicit (model-before-global, registration order); a hook that must run
after another (emailVersioning after auditLog) relies on it and per-model re-registration can break
it. Document the requirement + the cycle-check need, mechanism left open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ZLT-3008: drop dead deliveredAt column from CommunicationLog

No code writes or reads it and there's no `delivered` status in
CommunicationStatus — it advertised a delivery-confirmation model
nothing implements. Per the canonical comms design, delivery-receipt
fields (deliveredAt/delivered/bounced/complained) land in the same
change that wires provider webhook ingestion, not before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: arongreenspan <aron.greenspan@inixiative.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants