Add FEAT-013 and FEAT-014 tickets, update feature matrix - #3
Merged
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)Added FEAT-014: AI Developer Experience (
tickets/FEAT-014-ai-developer-experience.md)Updated FEATURES.md
Updated kanban-backlog.md
Updated docs/claude/ENCRYPTION.md
Implementation Details
Both tickets are comprehensive design documents with:
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