Loading…
Planning
Everything planned and in flight, in one place. Tell Claude “add a backlog thing” and it lands here. Once something ships it moves to the changelog and comes off this list.
An Asana reply should mark the message it answers as replied
Chad, 2026-08-18. When we reply to a client by EMAIL, the message being answered is marked replied (sendEmailReply calls answerMessage with replyToMessageId), so the thread stops asking for a response and the Communication KPI is right. Replying on the ASANA channel does not do this on either path: postAsanaReply (lib/comms.ts) posts the comment and never touches the message, so an Asana conversation we HAVE answered still reads as unanswered. Noticed while building email drafts — the draft path was deliberately left consistent with the direct one rather than diverging, so BOTH need the change together. FIX: have the Asana reply carry the id of the message it answers (the inline ReplyEditor already knows it; an Asana draft already stores reply_to_message_id) and call answerMessage after a successful post, exactly as the email path does — after the post, never before, so a failed post never marks it replied. Touch points: postAsanaReply, the asana_reply case in app/api/comms/thread/[id]/action/route.ts (which already accepts draftId), and components/comms/ReplyEditor.tsx. Chad: 'that is eventually how I want it to work'.
Audit for duplicated assets — share, never copy (components, helpers, API shapes)
Chad, 2026-08-13. We've repeatedly ended up with the SAME thing built twice in different places, and the copies drift (a fix lands in one, not the other). Now that CLAUDE.md has the "Shared assets — build once, import everywhere" rule, do a pass to see where we stand: find duplicated or near-duplicated assets and collapse each to ONE shared module every caller imports. Look for: UI components / pop-ups / quick-looks; date-time formatters (e.g. several shortTime / dayLabel / eventTimeLabel copies exist across the calendar files — lib/calendar-today, app/calendar/page, components/calendar/*); recipient / attendee pickers; money/slug/format helpers; repeated Tailwind class-sets that should be components; and duplicated API-response shapes. Deliverable: a list of the duplicates found, the single home each should live in, then consolidate the clear-cut ones (generalise the existing asset with a prop/arg rather than cloning). GOOD example to preserve: GcalEventPopup is already shared correctly (calendar grid + My Tasks import the one component).
⚠ HIGH PRIORITY — Finish email domain authentication (SPF / DKIM / DMARC)
Chad, 2026-08-14. So email the app sends (via Google Workspace) is trusted and stops landing in spam. Records live in DNS at GoDaddy (domain: strategicgrowtharchitect.com); DKIM is generated inside Google Workspace first. TWO dated steps: (1) NOW — turn DKIM ON: Google Admin console → Apps → Google Workspace → Gmail → Authenticate email → Generate new record (2048-bit) for strategicgrowtharchitect.com, add the given TXT record (host google._domainkey) at GoDaddy, then click Start authentication. Also confirm SPF exists at GoDaddy (TXT @ = "v=spf1 include:_spf.google.com include:mailgun.org ~all") and add a DMARC record (TXT _dmarc = "v=DMARC1; p=none; rua=mailto:chad@strategicgrowtharchitect.com; adkim=s; aspf=s; pct=100"). (2) ~ON/AFTER 2026-08-21 (about a week later, once SPF+DKIM confirm passing via mxtoolbox.com) — TIGHTEN DMARC in GoDaddy: change p=none → p=quarantine, and after another clean week → p=reject. Verify each stage at mxtoolbox.com (SPF, DKIM selector "google", DMARC). This is a GoDaddy/Workspace admin task (needs Chad), not code.
Client dashboard KPI helpers overflow / clip out of their card
Chad, 2026-08-12: on the client dashboard Scorecard (Communication / Quality / Timing KPI tiles), opening a KPI help popover (the "?" on a tile, e.g. "How Quality is measured") renders a panel that overflows its tile and clips under the neighbouring tile instead of floating above everything. Looks like a stacking/overflow issue: the popover is likely constrained by an ancestor with overflow:hidden (or an insufficient z-index / non-portaled positioning) so it is cut off rather than layering over the adjacent Timing card. FIX: let the helper escape its card — portal it to the body or raise it above the tile row (z-index) and make sure no ancestor clips it — so the full help text shows over the other tiles. Component is the per-KPI helper on the Scorecard tiles (app/[client] dashboard KPI strip). Cosmetic, not urgent.
Retire the slug === "plan" detector on deliverable registration (slug-retirement phase 2)
Chad, 2026-07-21: part of retiring the deliverable slug (full plan in docs/slug-retirement-plan.md). registerDeliverable (lib/repo-api.ts:1029) decides "this deliverable IS the initiative plan" from the string: input.deliverable_slug === 'plan' || input.type === 'plan', then writes initiatives.plan_deliverable_id (lib/repo-api.ts:1084). Semantic meaning smuggled into a slug string, and one of the reasons the slug is still load-bearing. FIX: drive "is this the plan" only off the explicit signal (type === 'plan', which already works) or an explicit is_plan flag, never the slug string. BEFORE removing the slug branch, run audit query C from docs/slug-retirement-plan.md to confirm no initiative currently gets its plan set via the slug==='plan' path and not the type path, so nothing regresses. Independent and low risk: ships on its own, no fleet or API-contract change. One of the two "should go regardless" cleanups; the sibling is the visibility read at app/api/deliverables/parent-options/route.ts:35 (phase 1).
No way to delete a task from an automated initiative template
FOUND 2026-07-27 while Chad was setting up the Daily Rundown automation template. An automation template initiative (initiatives.template_type = automation) holds template tasks (tasks.template_type = automation, asana_gid null). Once a task is added to the template there is no control anywhere to remove it: the automation builder / template editor has no delete affordance for its tasks, and the normal task-delete paths do not apply to template tasks. Chad worked around it by renaming the two unwanted tasks to "DELETE" so they could be found and removed by hand from the database (scripts/db.mjs). FIX: add a remove/delete control to the automation template editor for its tasks (and confirm the same for template initiatives themselves), so a template can be edited down without touching the DB. Until then, unwanted template tasks can only be cleared manually.
Comms messages need a SAFE delete + archived-message retention policy
ADDED 2026-07-23 alongside the History tab. The comms_messages table only grows — internal + external mail keeps landing and nothing ever leaves. Message-level ARCHIVE (get a message out of the working feed) is being built as its own increment: a comms_messages.archived column, an archive/unarchive action, and an Archived filter in History. This backlog item is the two pieces past that, deliberately deferred: (1) DELETE. No delete yet, on purpose — deleting a message is destructive and, because comms_messages is the single source of truth for the board, the timeline, the KPIs and the enrichment queue, a bad delete is unrecoverable. Build a SAFE delete: back-up-then-delete (export the row + its comms_message_threads / comms_items / comms_links to a cold store or a soft-deleted table) so it can be restored, rather than a hard DELETE. (2) RETENTION. Archived messages should age out automatically after some period (Chad: "stuff that's archived probably needs to be cleared out after a certain period of time, but we'll make that decision later") — the period is an open decision. Likely a scheduled job that runs the safe-delete over messages archived longer than the chosen window. Decide the window with Chad; keep the unmatched/ignored noise in mind (that is a big share of the volume). Until this exists, archive is hide-only and the table grows unbounded.
Audit the live schema against the migration files (drift check) — COLUMNS DONE, constraints/indexes left
COLUMN-LEVEL AUDIT DONE 2026-08-04: diffed every public column on production vs the DEV database (ref qtwgytwcnqgdozjjqvqo) both directions — 672 prod columns vs 671 dev, and the ONLY delta was tasks.initiative_name (hand-applied to prod, never migrated). It broke every CLIENT VIEW on statusdev ("We hit a snag loading this view") because getClientTaskRollup selects it and throws. Tracked via migration 111 (add column if not exists — no-op on prod) + schema.sql + docs/tasks-initiative-name-db-changes.md. STILL OPEN: constraints and indexes were NOT diffed (the 2026-07-20 audit found the blocked_state check constraint from migration 061 silently rejecting two codes the UI offered), and there is still no reusable script under scripts/ (the diff was done ad hoc with information_schema queries via db.mjs). Turn the column diff into a committed scripts/ audit that also covers constraints + indexes so it is re-runnable. ORIGINAL: FOUND BY ACCIDENT 2026-07-20 and almost certainly not the only case. clients.timezone exists in production, is read and written by the client form / PATCH /api/clients/{slug} / lib/types.ts, and drives the sidebar client clock — but it was in NO migration file. `grep -r timezone supabase/` returned nothing. Someone applied it to the live database by hand and never wrote it down. Migration 095 catches that one column up (add column if not exists, so a no-op on the live DB). IT ONLY SURFACED because the form touching it happened to be getting rewritten — nothing systematic found it, which is the actual problem. WHY IT MATTERS: nothing is broken today, and that is exactly what makes it dangerous. What breaks is a database rebuilt FROM THIS REPO — a fresh environment, a restore, a local instance with real creds — which comes up missing the column and fails on write with a column-not-found error pointing nowhere near the cause. The longer the drift sits, the more of it there is and the less anyone remembers applying it. WHAT TO DO: query information_schema.columns for every app table on production, generate the same list from supabase/schema.sql + every migration in order, and diff them. Both directions matter — a column in the DB but not the migrations (like timezone) AND a column in the migrations that never landed. Then write a migration for each gap using `if not exists` so it is a no-op against production. Cheap to run, and worth keeping as a script (scripts/) so it can be re-run rather than rediscovered. ALSO CHECK constraints and indexes the same way: the audit that found this also found that the blocked_state check constraint from migration 061 was silently rejecting two codes the UI offered, which had never worked and nobody had noticed.
The internal Asana integration shipped OFF on 2026-07-16 (changelog "Fully off Asana"): nothing reads or writes an Asana workspace for tasks/initiatives, and lib/asana.ts is deleted. Asana stays ONLY as the per-client comms channel (pull a client's messages in, reply) — that we keep. What remains is naming + logic debt from the mirror era. It is NOT cosmetic: the "mark complete doesn't stick" bug (fixed 2026-07-17) came straight out of it, and the same shape is lurking elsewhere.
⚠ Finish getting Asana out — it should not identify a company or gate a write
Chad, 2026-09-17: "This Asana stuff is driving me crazy because I've spent so much time working on removing Asana from this. Asana has nothing to do with this app anymore." He hit it as a real blocker: '+ New initiative' was INVISIBLE on the Northpoint training company, because a company with no Asana project could not be identified. THE CLASS OF BUG: an Asana identifier gating a feature that never touches Asana. Counted 2026-09-17, so this is measured, not guessed: 4 places resolved a company by clients.asana_project_id, 25 write routes call guardAsanaWrite, and 129 files mention asana at all. FIXED THE SAME DAY (see the changelog, 'A company with no Asana project can create initiatives again'): /api/work/create-initiative now takes our clientId (projectGid is a legacy fallback and the server prefers clientId), passed from the client header, Full View, the rundown wizard and the split-initiative button; getLinkableTasksForProject (the 'Follow-up of' picker, which was returning an EMPTY sibling list on such a company) takes our id or the gid; /api/deliverables/options accepts ?client=<id>. WHAT IS LEFT, in the order it matters: (1) app/api/asana/initiative/route.ts still resolves the company by .eq('asana_project_id', …) — the last identity coupling; it is a legacy Asana-named route that writes to Supabase, so decide whether it is retired or re-pointed. (2) RENAME guardAsanaWrite / verifyAsanaWriter (lib/api-guard.ts, 25 routes) to what it actually means — edit rights. The session flag is NOT an Asana grant (see the session-flag note: false makes the whole app view-only), and its 403 still reads "Your email isn't an Asana user", which is the single loudest reason the app LOOKS like it needs Asana. Pure rename, no behaviour change, big readability win. (3) Sweep for other 'assumes a project gid exists' guards: the pattern to grep is a component returning null or an empty list when projectGid/projectId is missing, plus any route whose only company lookup is the project column. (4) The asana_* COLUMNS are a separate, already-deferred item below ('Rename the vestigial asana_* columns / routes / files') — do not fold them in here; that one needs the write-watch and an expand/contract migration. KEEP, deliberately: the per-client comms channel (lib/asana-client.ts, client_asana, comms_sources, the poll cron) — pulling a client's Asana messages in and replying is still a real integration. SEQUENCE: (2) first because it is a rename with no risk and removes most of the smell, then (1), then (3). Each is its own PR, never bundled into a bug fix.
IMPORTANT: the off-Vercel secrets backup is incomplete (21 of 41 values missing)
Chad, 2026-09-11, IMPORTANT. This is the one gap in disaster recovery, and it is the only piece Claude CANNOT fix: the values are not readable from anywhere Claude can reach. THE PROBLEM: Vercel marks most env vars sensitive (decrypt-disabled), so `vercel env pull` returns them as EMPTY STRINGS. Verified 2026-09-10. Vercel is where secrets GO, not a place they can be RECOVERED from. The only off-Vercel copy is the gitignored `sga-status/.env.production.backup`, and it currently holds 20 of 36 entries with the rest blank, plus 5 variables missing from it altogether. It is a partial backup that READS LIKE A COMPLETE ONE, which is worse than an obviously empty one because it would be trusted during an incident. BLANK IN THE FILE (16): ASANA_ACCESS_TOKEN, CRON_SECRET, GOOGLE_CALENDAR_ENABLED, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, INTERNAL_ASANA_ENABLED, NEXT_PUBLIC_SUPABASE_ANON_KEY, NEXT_PUBLIC_SUPABASE_URL, SESSION_SECRET, SFTP_HOST, SFTP_PASSWORD, SFTP_PORT, SFTP_USER, STATUS_READ_WRITE_TOKEN, SUPABASE_SERVICE_ROLE_KEY, SYNC_SECRET. ABSENT FROM THE FILE ENTIRELY (5): GOOGLE_MEET_ENABLED, GOOGLE_SUBMIT_CLIENT_ID, GOOGLE_SUBMIT_CLIENT_SECRET, OPENROUTER_API_KEY, PASSWORD_LOGIN_ENABLED. DO THIS FIRST, TODAY, EVEN IF NOTHING ELSE: put COMMS_CRED_KEY into a PASSWORD MANAGER. It decrypts stored mailbox credentials, Google refresh tokens and personal API keys — without it those columns are unrecoverable even from a perfect data dump, and it is the only value on this list that cannot be re-issued by anybody. It is currently in the local file only, so it dies with the laptop. Do the same for BLOB_READ_WRITE_TOKEN / STATUS_READ_WRITE_TOKEN and their store ids: they address Vercel Blob storage and cannot be reissued from anywhere else if Vercel is lost. THEN: copy the remaining values out of the Vercel dashboard by hand (they ARE visible there, just not via the CLI) into the backup file AND the password manager. A gitignored local file is not a backup on its own. EVERYTHING ELSE IS RE-OBTAINABLE and the runbook says from where: Supabase keys from the Supabase dashboard, Google from the Cloud console (project sga-status, 728712548365), Asana by regenerating, SFTP from the host, and SESSION_SECRET / CRON_SECRET / SYNC_SECRET are arbitrary — invent new ones (rotating SESSION_SECRET just signs everyone out once). So a total Vercel loss is about an hour of re-issuing keys PROVIDED the two irreplaceable items above were kept somewhere else. Full inventory and the maintenance rule: docs/RESTORE.md, plus the "Recovery kit" section of CLAUDE.md (PR #399).
v1/v2 are live; these are what remain.
Closed-tab login invalidation
A closed-tab session can't be centrally revoked with today's stateless 7-day token. Needs a session-model decision (short sliding token refreshed by heartbeat vs. per-request check).
Team-wide attendance view
One timeline/summary of everyone's logins, breaks, logouts, and hours-available-per-day. Per-member drill-down already exists.
Mobile presence
Bring the sidebar presence control and the idle logout prompt to mobile (desktop-only today).
Claude Code timer reconciliation
Make headless /submit hours play nicely with the browser timer: dedupe an open session, auto-stop on submit, handle the QA-assignee gap after a headless submit.
⚠ AI rundown audit: cut the noise — 82% of its findings are not real issues
Chad, 2026-08-25, from the first all-clients sweep (2026-08-20, the only one in rundown_ai_checks so far): 92 findings across 9 clients, of which ~17 were real and ~75 were noise. Chad: "it is finding literal things that are wrong, and it is also finding stuff that predated all of our due dates, so it is causing a lot of errors." IT IS NOT ALL BAD — on the real signal it did well: there were 17 overdue open tasks in the whole system and it found 14, plus both genuine client blockers and the one unanswered review thread. The noise is four mechanical categories, counted from that sweep: (1) "Initiative has no pipeline stage set" — 28 findings, 30%. NOT A DEFECT: stageActiveIndex (lib/initiative-stage.ts) maps null to index 0 = Strategy, and the whole app, including the Timing KPI, treats an unset stage as Strategy. 36 of 64 open initiatives have no stage, so this fires on 56% of them daily, forever. Delete the rule from TIMING_SYSTEM in lib/rundown-check.ts. (2) Work due TODAY or TOMORROW reported as a problem — 27 findings, 29%. SBMF alone had 10 "Promised Client Date at Risk" that were all just due that day. The prompt literally says "past or today", which invites it; the Timing KPI already settled that due-today is not overdue. (3) "Task has no due date" — 10 findings, mostly internal SGA tasks. (4) "Open task under a completed initiative" — 10 findings, which is the stray-task pack's own framing echoed back as a finding. CHAD'S RULE (2026-08-26): "anything that is marked as complete does not need to audit — it has already been completed and handed in, especially for missing stuff like it is." So completed initiatives and completed tasks must not generate findings at all, and above all not "missing X" findings. ALSO: nothing dedupes (the same task appears as "Overdue Task" and again as "Open Task in Closed Initiative"), nothing caps (SGA got 23 findings, SBMF 30 — unreadable), and labels are not checked against their own facts (Crome had "End Date Today with No Plan" for an end date of 31 Aug; DB-LTD had a stage issue filed as "Stage Date Blown"). FIX: drop rules (1) and (2) from the prompts, exclude anything completed, dedupe to one finding per underlying object, and hard-cap around 5 per client sorted by severity. Modelled against that sweep this turns 92 findings into roughly 15, and those 15 are the ones you would actually raise in a status meeting. Prompt-and-filter only — no migration.
Template library: task, initiative, plan and deliverable templates (BUILT, on dev for review)
BUILT 2026-09-28 on branch feature/template-library (migration 147), deployed to statusdev for Chad's review; delete this item when it ships to main. Chad, 2026-09-28. We run a lot of the same work over and over, so we want templates to start from instead of building every initiative, plan and task from scratch. Design agreed in conversation, nothing built. STORAGE: like the automation templates, NOT as HTML files. A template is a real initiative / task row with a new template_type (e.g. 'library') and its intent in template_config (description, checklist, priority, full workflow, minimum billable hours, dates as day offsets, owners as ROLES like PM or Designer rather than people). template_type already keeps these rows off boards, My Tasks and reports. Using a template is always a COPY: editing a template never changes work already made from it. CATEGORIES: one shared category tree for all libraries, nestable to any depth (a self-referencing categories table with parent_id + sort_order; each template sits in one category). The Upload QA config is only a fixed two levels, so do not copy that shape. SIDEBAR: Templates becomes a submenu (like Automations): Plans & reports (today's /templates page, unchanged), Initiative library, Task library, Deliverable library. All three libraries are built TOGETHER (Chad, 2026-09-28: the deliverable library is not a later 'coming soon'). VIEWING: a library page looks like a client board (same components), and clicking a template opens the REAL initiative pop-up with a templateMode prop (never a copied pop-up): day offsets shown as real dates from a sample start date, role chips where owners go, {{placeholder}} chips with sample answers, the setup questions where the plan strip sits, View plan rendered from the template data. Every live-only action in InitiativeCard (timer, comments, review tool, plan import, KPIs, Split) must be hidden or swapped in template mode; plan this before coding. HOW TEMPLATES ARE USED (Chad's rule): (1) INITIATIVE / PLAN TEMPLATES never create work directly. They are ATTACHED to a task (the plan task) through AddLinkPanel: pick the library (Plans & reports / Initiative library / Deliverable library), then category and subcategories, then the template; several can be attached (e.g. five initiative templates on a monthly-plan task). /brief and /start deliver each attached template as DATA (tasks, checklists, offsets, roles, minimum billable hours, setup questions), Claude Code asks the setup questions and gathers context, writes the plan with the tasks in its markup, and the normal plan import creates the work. That keeps client approval before tasks exist, keeps one way work gets created (import, with preview + no-overwrite), and the monthly import works unchanged. The setup answers live in the plan itself (mostly The brief tab), not stored separately. Record which template a plan came from (a small markup attribute) so we can see which templates get used. Template tasks need stable keys (keys are per-initiative, so reuse across companies or twice in a month does not clash). (2) TASK TEMPLATES are the one case added directly: the New Task wizard's first step gets a 'Use a template' link that browses the task library by category and PREFILLS the wizard, which you still go through (the same CreateTaskWizard, no second form). The wizard never offers initiative templates and the attach panel never offers task templates. Initiative templates can INCLUDE task templates BY REFERENCE (editing the QA task template updates every initiative template that uses it) plus their own one-off tasks. SYSTEM TASKS become editable task templates: today two are hard-coded, 'Write the plan' (title in app/api/work/create-initiative/route.ts, description + checklist in lib/plan-task.ts) and the QA review task created on submit (lib/submissions.ts). A 'System templates' section in Settings picks which template each system task uses; ship each pointing at a template holding today's exact content so nothing changes on day one, and fall back to the built-in content if its template is deleted. The existing 'Seed the QA follow-up task with the Upload QA checklist' item folds into the QA template. DELIVERABLE LIBRARY (Chad, 2026-09-28): a library of finished work to start from, so a deliverable is not built from scratch every time. Two ways in: (a) SWIPE FILES, uploaded (or a link plus a screenshot) as examples to learn from; (b) OUR OWN WORK, promoted from a real deliverable with an 'Add to library' action on the deliverable or one of its versions (pick category, name it, a line on why it is good). A library entry is a SNAPSHOT COPY stored in our own file storage (lib/storage.ts), never a pointer to the client's deliverable url, because client deliverables get new versions, get retired, and have lived on hosts that died. Record where it came from (source client + deliverable) internally only. Rules: client work is reviewed before it goes in and must not carry that client's confidential details, and library entries are never shown to another client; swipe files from outside are internal reference only, never sent to a client as ours. Used like the other attach-only libraries: attached to a task through AddLinkPanel (Deliverable library, then category, then entry) and delivered in /brief and /start as a file Claude Code can open as the starting point. Uses the same shared category tree. SETUP QUESTIONS: a template carries a list in template_config (question, why it matters, answer kind text/choice/date/yes-no, optional default, required before the plan can be written); the brief shows them so Claude Code asks them first, and template text can use {{placeholders}} they fill. Per the automation rule, anything added to a task or initiative is added to the template path in the same change. BUILD ORDER: the shared categories + sidebar submenu, minimum billable hours (below) and task templates first; then initiative templates and the deliverable library (built in the same stretch); then plan templates with setup questions. Later, an automation could start from a library template instead of keeping its own copy.
CORE MODEL SHIPPED (2026-07-19, verified against the prod DB 2026-08-06): a deliverable IS its own thing now — deliverable_versions (its own history) and deliverable_links (the many-to-many, passed between tasks/initiatives) both exist, deliverables.initiative_id/task_id mean "created in" rather than "owned by", and description/category/subcategory + parent_id (sub-deliverables) are live. Full model + the three stages: docs/design-deliverables-model.md — read it before touching deliverables, review_comments, upload_submissions or lib/reviews.ts. WHAT REMAINS is the follow-on tail below: two overdue cleanups (drop the deprecated kind column + the versions jsonb, both still present), the submit-path features (self-describing API, pick a real deliverable + a description field, a managed tag vocabulary), grouping the review page by parent (gated on real parent data), and minor pop-up polish. Beta and main share ONE database, so every migration is live the moment it is applied.
Reflect the deliverables rework in the SGA house rules (running list)
FLAG, not a fix. Chad, 2026-07-19: everything new for deliverables has to be reflected in the read-only sibling repo ../sga-house-rules, which is the source of truth for how the whole system works and is NEVER edited from this repo. The running list of what is owed lives in docs/house-rules-updates-owed.md — clear an entry by making the change in the sibling repo, committing and pushing FROM THERE, then deleting the entry. Currently owed: the /submit contract (see the separate item below), technical/review-tool.md (versions stop being a jsonb blob in Stage 2, and version numbering PRESERVES GAPS — never renumber), the worker rules in CLAUDE.md + sop.html (the naming rule: the label is a name a human owns and can change; the slug is an address generated once and never rewritten), an obsolete warning if any SOP tells workers not to rename deliverables (renaming archives now instead of deleting, migration 084), and the tag vocabulary once tags are in real use. For anything touching an endpoint, field or response shape, regenerate the mirrored contract with node ../sga-house-rules/scripts/sync-api-contract.mjs --write — the sibling repo then needs its own commit and push.
The engine, the editor, the time trigger, run history and client owners SHIPPED on 2026-07-18 (see the changelog, "Automations") — an automation pairs a trigger with an action, and the palette grows one at a time. What remains is the next two triggers (each needs a signal we do not have yet) and more actions. NOTE: the cron has never actually fired — Vercel only runs crons on production, so a schedule cannot be tested on beta. The first real automation is also the first proof the timer works.
Trigger — new client review feedback → "check it" task
When feedback is left on a review for an initiative (by a client, or anyone who wants a task created), fire an automation that creates a "check the feedback" task so it is not missed. Needs the review tool to signal back that feedback landed (shared-DB read or a webhook — see the cross-system integration backlog). One of the two use cases Chad flagged as most useful first. The engine and the action side already exist; this is purely the trigger source.
Trigger — new client email → timed response task
When a new email comes in linked to a client, create a task to respond within a set time window, assigned to that client's owner. Comms ingest already captures inbound client email, so the trigger source exists. Client owners shipped with the automation build, so the dependency is cleared — this is now just wiring comms ingest into the dispatcher. The other of Chad's two most-useful-first use cases.
More actions than "create an initiative"
The action side is deliberately a palette of one. Obvious next candidates: create a standalone task (not wrapped in an initiative), and send a notification / inbox item. The engine already dispatches on action_kind, so adding one is a branch plus an editor, not new plumbing.
Batch-mark a review sitting from the flat Communication feed too
Chad, 2026-08-26, noted while shipping review batching. A client review sitting is now ONE decision with one "Mark all replied" (lib/comms-batch.ts) in the two places the batch is whole: the Comms Hub thread (components/comms/ThreadDetail) and the initiative Communication tab (components/work/InitiativeCommsTab). The FLAT feed (components/comms/CommsHistory) still marks message by message, because that view is paginated and not thread-scoped, so a batch can straddle a page boundary. GRADING IS UNAFFECTED — batchAnswered requires every message in the batch, so half-clearing one there cannot make it look handled — it is only the convenience button that is missing. FIX would be to group within the loaded page and either mark just the visible slice or fetch the batch's siblings before marking. Low priority: the flat feed is the archive/search view, not where the queue is worked.
Review tool: a "I'm finished" button that emails the client's feedback back to us
Chad, 2026-08-26. THE PROBLEM IT SOLVES: client review feedback is currently the only client communication with no scoreable moment. Every annotation, approval and answered request is mirrored into comms one message at a time (bridgeReviewToComms, lib/reviews.ts), and those land on the initiative's Asana thread, which is flagged internal — so they are deliberately off the Communication board and, since 2026-08-26, out of the Communication KPI (lib/kpis.ts drops internal threads: you cannot be scored on a message the queue never showed you). That is the right call for a stream of annotations, but it leaves review work unmeasured, and a client can finish a review without anything arriving that says so. BUILD: at the end of a review, the client clicks a "Done" / "I have finished my review" button. That sends ONE email on their behalf (from them, to us) summarising what they just did — the deliverable and version, how many comments they left, whether they approved or requested changes, and the comment text itself so the mail is useful on its own. Because it is a real inbound client email on a normal client-facing thread, it lands in the queue like any other, needs a Replied / No reply needed decision, and IS scored by the Communication KPI. One email per review session, not one per annotation, which is the same "one sitting is one thing to answer" rule the batching already applies (lib/comms-batch.ts). KEEP AS IS: the individual review messages still mirror into the timeline exactly as they do now — we want to SEE them, we just do not score them. Do not un-flag the internal threads to make them scoreable; this button is the chosen answer to that instead. TOUCH POINTS: the review page (the client-facing side that posts comments/approvals), a new "finish review" endpoint alongside addApproval/addRequestResponse in lib/reviews.ts, and the existing send path (lib/mail-smtp / the Gmail send used by the Composer) to actually send it. DECIDE: which of our addresses it is sent to, and whether an unfinished review (client leaves without clicking) should nudge them or send automatically after a quiet period. Related but separate: the older "Threads wrongly flagged internal" item below is about ingest misclassifying genuinely client-facing threads; this one is not a misclassification, the initiative thread really is internal.
Distribute the branch / deploy workflow + environment model to the house rules (CLAUDE.md done)
Chad, 2026-07-28. DONE: the statusdev seed environment shipped (own Supabase DB, seed data, statusdev.sga.team) and the CLAUDE.md half is written — a full "Branch Strategy - three environments" section plus "Staging and the promotion flow" and the DB-migration flow, with the stale "preview URL is staging" line corrected. Canonical flow: branch off main -> merge into beta to test on real login -> single-branch PR into main; never commit to a deploy branch; never merge beta -> main; risky/data-writing changes go dev -> beta -> prod, safe read-only skip dev; re-sync beta/dev from main after a promotion. LEFT TO DO: distribute the SAME rule fleet-wide via the read-only sibling repo ../sga-house-rules (the canonical source, see OPERATING.md) so every company repo carries it, not just this one.
DB backup / DR
Chad, 2026-07-29: substantial progress, not fully done. DONE: (a) the daily backup automation is ON and verified running (GitHub Action .github/workflows/backup.yml dumps all 54 tables to the private chad-sga/sga-status-backups repo every morning; latest run 2026-07-29). (b) A full plain-English restore runbook now lives at backup-restore.html in the repo root, covering the Supabase-lost, Vercel-lost and corruption scenarios plus a per-variable env reference. (c) All 32 production env vars were captured from Vercel into .env.production.backup (gitignored, local only). (d) Supabase upgraded to PRO on 2026-07-29, so managed daily backups (7-day retention) are now active and the free-tier auto-pause risk is gone. LEFT TO DO: (1) run the restore drill in docs/restore-drill.md once against a throwaway Supabase project, to PROVE restore works and get a real recovery time (nothing here is trusted until drilled). (2) Store .env.production.backup in a password manager: it only exists on Chad's laptop today and holds COMMS_CRED_KEY, which is irreplaceable (lose it and encrypted mailbox tokens + personal API keys cannot be recovered even from a perfect data dump). (3) Decide on Vercel Blob file backup: deliverable uploads and work attachments live in Vercel Blob, not the DB dump, so losing the Vercel account loses those file bytes (the DB keeps only their URLs). (4) Optional: add Supabase PITR (a separate Pro add-on) if sub-day data loss would hurt. (5) Review and merge the docs branch.
Waiting on Chad before building.
My Tasks complete + Send-to-QA
Should My Tasks completion be permission-gated like the board, and get a "Send to QA" option?
Share buttons on Team / My-Tasks
OK to add a per-task client_slug just to enable share deep-links there?
Hours rules
Confirm the day-based hours rule and the completion → month auto-stamp before building either.
Out-of-spec definition
Does "Ready to send" count as "sent to client" for the client-vs-internal quality metric, and do we wire the per-person internal side at the same time?
Statuses (and stages) should be a TABLE with a stable ID, not hardcoded label strings
Chad, 2026-08-25. THE PROBLEM: a status is stored as its DISPLAY LABEL. tasks.asana_status / initiatives.asana_status hold the literal name, and so do status_transitions.from_status / to_status, which is what the Quality KPI reads (lib/kpis.ts .eq('to_status', 'Awaiting final approval') at ~547 and ~821). The eleven statuses themselves are a hardcoded array in lib/asana-status.ts (ASANA_STATUS_OPTIONS), their descriptions are a hardcoded map right beside them (STATUS_DEF, the dropdown tooltips), and their stage grouping is a third hardcoded map (STATUS_GROUP_NAMES). CONSEQUENCE: renaming a label, or fixing a typo in a definition, is a code change plus a deploy, AND every historical transition row keeps the old string forever, so reports show both names side by side. Chad wants labels freely renameable as the process keeps improving, including changing one back. ALSO VESTIGIAL: the gid round-trip. The UI converts the label to an Asana gid (statusGidForName in components/work/MyTaskStatus.tsx:49), POSTs statusGid, and app/api/asana/task/[gid]/route.ts:360 converts it straight back and stores the NAME. Confirmed 2026-08-25 that nothing writes a status to Asana: lib/asana-client.ts is the only file touching app.asana.com and lib/comms.ts is its only importer (reads plus postTaskComment, the per-client comms channel we keep). So the ID exists, points at a system we no longer write to, and is discarded. THE FIX: a `statuses` table keyed by an opaque surrogate id (NOT a slug, which would be derived from the label and would go stale the moment a label is reworded, which is the same trap one layer down). Columns: id, label, description, stage, sort_order, is_send_back (requires a reason note), is_handoff (out of the doer's hands, stops the overdue clock), is_client_waiting (Waiting on Client lane), retired_at (hide from pickers without deleting history). Then tasks.status_id, initiatives.status_id, status_transitions.from_status_id / to_status_id all store the number, code references them via a constants module so no bare magic numbers, and history renames itself because it points at the ID. STAGES HAVE THE IDENTICAL DEFECT and should probably get the same treatment in the same pass: STAGE_LABELS (lib/initiative-stage.ts) and STAGE_DEF (lib/asana-status.ts) are hardcoded, and initiatives.stage stores a slug guarded by check constraint initiatives_stage_chk (migration 113), so renaming a stage needs a code change AND a constraint migration. Adds blast radius (the stage strip, the client Timing KPI and the daily rundown all read stages), so it is a decision, not an assumption. WHAT A TABLE DOES NOT SOLVE, know this up front: the guided state machine (which actions are offered from which status and where each leads, lib/status-flow.ts) stays a switch in code, keyed by ID instead of by name. Labels, descriptions, grouping and ordering become editable data; adding a genuinely NEW pipeline step stays a code change. That is the right line for now. RISK, assessed honestly 2026-08-25: the CODE side is essentially find-and-replace and is LOW risk, because the string literals are finite and TypeScript flags every call site once the type stops being a string (tests already exist in status-flow.test.ts and overdue.test.ts). The DATA backfill is the part that needs an eye, not because it is hard but because it fails SILENTLY: an .eq() against an unmapped string returns zero rows instead of erroring, so a bad mapping shows a wrong Quality number rather than throwing. And the mapping is NOT just the current eleven names, because history holds retired ones: 'New client feedback' (now Revisions) and 'Waiting on client approval' (now Awaiting final approval) are both noted in lib/asana-status.ts, and migration 094 retired four blocker statuses (Needs strategic input, Waiting on dependency, On hold, Waiting on client assets) while updating only tasks and initiatives, so those strings are probably still in status_transitions. DO THIS FIRST, it de-risks the whole job in one step: run a read-only `select from_status, to_status, count(*) from status_transitions group by 1,2` to enumerate every string ever stored, then build the mapping exhaustively from that list. SEQUENCE: expand/contract per CLAUDE.md (add status_id alongside the name columns, backfill, convert readers in lib/status-flow.ts, lib/overdue.ts, lib/kpis.ts, lib/kpi-exclusions.ts, lib/inbox.ts, my-tasks and the API routes, retire the dead gid round-trip, and only DROP the name columns in a LATER change once nothing reads them). Rehearse the migration on the DEV database (statusdev) first. RELATED: this overlaps the deferred 'Rename the vestigial asana_* columns' item below, which already notes asana_status has no native twin and must be renamed rather than dropped. If both are done, do this one first or fold the rename into it, since it replaces that column outright. INTERIM (agreed 2026-08-25, may already be shipped separately): rename the DISPLAY label of 'Awaiting final approval' to 'Awaiting approval or revisions' via a label map, because almost everything sent to a client comes back as revisions and the current wording reads as a final sign-off. That map is the exact seam this table needs (it is where `label` comes from once the table exists), so it is a step toward this design, not a detour. Chad decided NOT to add '/revisions' to 'Awaiting spec approval': there is no revisions path from it in the guided flow, so the slash would promise a route that does not exist.
Retire the dead dual-source (linked ? asana_* : authored) branching
DONE 2026-07-28 via PR #291 (single-branch PR into main; verified on beta). WHAT SHIPPED, all read-only (no schema, no writes): mapInitiativeRow now reads the initiative owner as `owner ?? asana_assignee` (the board owner-avatar was blank for wizard/automation-created initiatives, which set `owner` and leave the legacy asana_assignee NULL, while the pop-up read owner correctly), and five secondary assignee reads were collapsed to native-first so the board and these paths cannot disagree: lib/hours.ts, app/api/team/priority/top/route.ts, lib/repo-api.ts (task + dependency), lib/follow-up.ts. RESIDUAL, optional and LOW: the title / name / description prefer-mirror reads are still asana_* first, but they are masked (asana_* is NULL on new rows) so they cannot diverge today; collapse only if convenient. The full field-by-field audit is in the 2026-07-28 session handover (docs/SESSION-HANDOFF.md). The column RENAME is the separate deferred item below. ORIGINAL CONTEXT: ROOT CAUSE of the completion bug — do this first. In the mirror era an item was either an Asana mirror (asana_* columns) or authored in-app (title / authored_status / assignee), and reads chose between them with `linked = !!asana_gid`. Post-exit EVERY task/initiative is DB-native (asana_gid null), so the "authored" branch is effectively dead: authored_status is frozen at create ("todo") and never advanced, yet writes still land in the asana_* columns. Wherever a write updates asana_* but the unlinked read returns the authored column, the change silently reverts on refresh. Fix: collapse to ONE source at every site: lib/data.ts mapTaskRow (title/status/assignee) + mapInitiativeRow (name) + the slim getClientTaskRollup select. CORRECTIONS from the 2026-07-19 audit: (a) lib/kpis.ts does NOT read assignee at all (it only carries the `linked` branch for status) — the old claim here was wrong; (b) for ASSIGNEE the live column is `assignee`, not asana_*: mapTaskRow line 116 returns row.assignee whenever asana_gid is null, and that is what the UI shows — so "asana_* is the live column" is true for title/status but NOT assignee, and any collapse has to go column by column; (c) the initiative-assignee "still to audit" note is stale — the reassign route does write initUpd.owner and initUpd.asana_assignee. CONFIRMED TWINS — (1) completion: FIXED 2026-07-17 (asana_completed made authoritative in the read). (2) assignee: FIXED 2026-07-17 (the reassign route now writes BOTH asana_assignee AND the authored assignee, matching name/title) — before the fix a DB-native reassign/unassign wrote only asana_assignee, so it did not stick on refresh and the task matched BOTH the old and new owner in My Tasks (getPersonTasks .or). BACKFILL RESOLVED 2026-07-19 — DO NOT RUN IT. This entry used to propose "tasks where asana_gid IS NULL AND asana_assignee IS DISTINCT FROM assignee → copy asana_assignee into assignee". Checked against real data: ZERO rows need it, and that predicate would have CORRUPTED 27 tasks — every task created through lib/create-task.ts sets `assignee` and leaves `asana_assignee` NULL, so they all match "IS DISTINCT FROM" and would have had NULL copied over a valid owner, mass-unassigning the board. If a backfill is ever revisited it must be guarded with `asana_assignee IS NOT NULL`. Unassigned-pre-fix rows can't be distinguished and stay until re-set. SAFE, never buggy (write BOTH columns): task title (asana_title + title) and initiative name (asana_name + name); status name reads asana_status link-agnostically. STILL TO AUDIT: initiative assignee — the route only writes taskUpd for assignee, so an initiative reassign is a no-op; confirm initiatives are meant to be reassignable at all. THIRD TWIN, FIXED 2026-07-19 — the My Tasks INBOX: lib/inbox.ts filtered on asana_assignee AND required a non-null asana_gid, so it was blind to every task created since the exit (29 tasks; 11-13 missed notifications per person). Now matches both identity columns and both comment keys (task_id / asana_gid). Same shape, third occurrence — which is the argument for collapsing rather than patching. STILL AUDIT-WORTHY, same family: lib/hours.ts, lib/repo-api.ts and app/api/team/priority/top/route.ts all prefer asana_assignee over assignee (the opposite of what mapTaskRow displays), so they can disagree with the UI. The remaining work here is to collapse the dual-source reads so this class cannot recur.
Rename the vestigial asana_* columns / routes / files
DEFERRED (Chad, 2026-07-28): do NOT start this until (1) the statusdev seed environment exists, so a column RENAME can be rehearsed against a throwaway DB with fake data, and (2) a few-months write-watch has confirmed which asana_* columns are still written. From the 2026-07-28 audit the classification to act on: asana_status has NO native twin, so it must be RENAMED, never dropped; asana_description / asana_notes / authored_status are dead and are DROP candidates; asana_gid is overloaded (also the comms thread<->initiative and team-identity join key) so it must NEVER be dropped. Sequence: #5 (done) -> statusdev + write-watch -> this. ORIGINAL AUDIT: AUDITED 2026-07-19 — confirmed NOTHING in the tasks/initiatives path makes a network call to Asana. lib/asana.ts does not exist; `grep "fetch("` across app/api/asana/ returns zero hits (all four routes are Supabase writes with a legacy name); lib/asana-status.ts is 245 lines of constants. The ONLY live Asana network code is lib/asana-client.ts, imported solely by lib/comms.ts, serving the per-client comms channel (in via /api/comms/poll, out via /api/comms/thread/[id]/action) — the piece we deliberately kept. So this is naming debt, not integration. Also vestigial: the guardAsanaWrite gate (lib/api-guard.ts → verifyAsanaWriter) only reads a boolean hardcoded true at login, and its 403 still says "Your email isn't an Asana user." Cosmetic but large, and the reason "why is Asana still in here". The columns where the platform now stores its OWN data are still named asana_* (tasks: asana_title, asana_description, asana_status, asana_completed, asana_assignee; initiatives: asana_name, asana_description, asana_notes, asana_status, asana_completed), the write route is /api/asana/task/[gid] (+ /api/asana/{subtask,initiative,users}), and lib/asana-status.ts still carries Asana custom-field GIDs. None of these talk to Asana. DB REFERENCES / CONSEQUENCES to untangle FIRST — this is exactly where a blind rename would breed more bugs: (a) tasks.asana_gid / initiatives.asana_gid are NOT purely dead — asana_gid is still the address-by-gid fallback in the write route, and initiatives.asana_gid is the join key for the LIVE comms link (thread_initiatives.initiative_asana_gid). DB-native initiatives store their OWN id there now, so the column is overloaded (an Asana gid OR our uuid); renaming/dropping it risks breaking comms thread↔initiative linking. (b) team_members.asana_gid is still the user-identity key the reassign flow resolves against (getMemberNameByGid) — reassignment breaks if it moves without care. (c) Unique indexes ride these columns: tasks_asana_gid_idx, initiatives_asana_gid_idx, clients_asana_project_uidx. (d) LEGITIMATELY Asana — KEEP AS-IS: client_asana (asana_workspace_gid, asana_project_gid), comms_sources, and the /api/comms/poll cron; that IS the per-client channel. Plan: a rename migration for the mirror columns + a codemod across the ~225 files referencing "asana", shipped as its own PR on a preview — never bundled into a bug fix. Low functional risk ONCE the dual-source logic above is gone; until then it touches live read paths.
IMPORTANT: replace Zoom with Google Meet for scheduling
Chad, 2026-09-10. IMPORTANT, but explicitly NOT today ("the calendar is not essential, getting these meetings on here is essential"). The transcript half of this SHIPPED TO PRODUCTION the same day (PR #394, migration 137): meetings hosted on Google Meet file themselves into the Meetings workspace, routed to a client or the company by attendee, visible only to the people who were on them. THIS item is the other half. WHY IT MATTERS NOW: the auto-filing only works for meetings on GOOGLE MEET, and most of the recurring calendar is still Zoom (Dan Chad Daisy Chris weekly, Classic Marketing Meeting) or Microsoft Teams (API Ziker). As of 2026-09-10 the ONLY recurring Google Meet meeting is the daily SGA Company Meeting. So every Zoom meeting is a meeting that silently never files, and moving scheduling to Meet is what makes the feature cover the actual calendar rather than one meeting. THE WORK: app/api/meetings/schedule/route.ts currently creates a Zoom meeting on the host (lib/zoom.ts, Server-to-Server OAuth; zoomMode none/create/link in components/calendar/CreateMeetingWizard.tsx) and pastes the join link onto a Google Calendar event. Switch it to ask Calendar for conference data (conferenceDataVersion=1 plus a createRequest with hangoutsMeet) so the event carries a real Google Meet link, and retire the Zoom path cleanly: the wizard offers Google Meet or a pasted link, lib/zoom.ts and the ZOOM_* env vars go, and scheduled_meetings keeps zoom_meeting_id as a DEAD column (expand/contract, never a live drop) alongside a new google_meet_code. GOTCHA that decides whether this actually pays off: a Meet link on the invite is not enough on its own. Transcription has to be on for the meeting too, which comes from the "Transcribe the meeting" checkbox under Video call options > Meeting records, defaulted on by the Admin console Gemini setting "Automatic note-taking = For all hosted meetings". The scheduler should set that checkbox on meetings it creates rather than relying on the domain default, so a scheduled meeting is guaranteed to produce a transcript. Full Google-side detail: docs/google-meet-setup.md. Keep the separate Zoom TRANSCRIPT item below PARKED: it is superseded for any meeting that moves to Meet, and only matters for historical Zoom calls.
info@ cutover Phase B — remove the IMAP company-inbox path once OAuth is proven
Chad, 2026-08-15. info@ now pulls AND sends over Google OAuth (comms_sources id 8850f42b…, shared=true). The cutover guard (isCompanyInboxOnOauth in lib/mail-imap.ts) already makes the poll SKIP the IMAP company-inbox pull for info@ while info@ is an active OAuth connection — so IMAP is currently deactivated, NOT removed, and is a one-switch fallback (flip the oauth row inactive → IMAP resumes automatically). KEEP IT THIS WAY for a while as a safety net. LATER, once the OAuth pull has proven reliable for weeks: remove the IMAP path entirely — unset the IMAP_* env vars on prod, delete the IMAP-only code in lib/mail-imap.ts (ImapFlow ingest, getCompanyInboxStatus IMAP bits, the isImapConfigured/ingestNewMail branch in app/api/comms/poll/route.ts) and the "Company inbox (info@)" IMAP section in Settings → Communications, leaving OAuth as the single path. Do NOT rush — there is no cost to leaving IMAP deactivated. Baseline was set 2026-08-15 (cursor gmail:<now>, no backfill); the guard + reversibility are the whole safety story.
Automatic meeting transcriptions into the workspace (needs per-person Zoom OAuth)
Chad, 2026-08-13. Deferred from the Zoom scheduling build (scheduling itself shipped). Goal: a meeting scheduled through Status auto-files its transcript into the meeting workspace under the company/client tagged at schedule time — the routing data is already stored on scheduled_meetings (kind + client_id + zoom_meeting_id, migrations 125/126, incl. ingested_at + comms_meeting_id for dedup/linking). BLOCKER we hit: Chad does not record every meeting, so the transcript lives in Zoom "My Notes" (AI Companion), not a cloud recording. Verified LIVE against Chad's account: the My Notes API is NOT reachable over our Server-to-Server app (401 "does not contain scopes:[my_notes:read:note]" even with my_notes:read:note:admin granted — S2S apps only get the :admin scopes, but the endpoint demands the user-level scope). The cloud-recording transcript path DOES work over S2S (proved — downloaded a real 43KB VTT) but requires recording, which Chad won't do. RESOLUTION: add per-person Zoom OAuth (a user-managed "Connect Zoom" app, same pattern/plumbing as the Gmail connect, reuse the comms_sources token store) so each host's My Notes transcript can be read with a USER-level token; forum evidence shows user-OAuth can read AI Companion transcripts. PROVE FIRST: stand up a user-managed OAuth app, connect one person, confirm a note transcript comes back for an UN-recorded meeting before rebuilding. Then on/after meeting end, pull the note transcript (my_notes include=transcript) and createMeeting() into comms_meetings with the stored kind/client. KEEP the Server-to-Server app for scheduling (it works). The recording-path transcript webhook was removed; nothing else needs a webhook.
Detect bounced sends and flag the original message as "delivery failed"
Chad, 2026-08-15. When we send from the app (POST /api/comms/send → Gmail), a HARD failure at send time already surfaces an error in the composer (Gmail rejects → we throw → red error, nothing recorded). The GAP: a message Gmail ACCEPTS but that BOUNCES later (bad/full mailbox, downstream reject) shows success in the app; the only signal is a "Mail Delivery Subsystem / mailer-daemon" bounce email that lands back in the sender's inbox minutes later. Since the sending mailbox is polled, that bounce already ingests into the hub as an inbound message — but nothing links it back to the original outbound or flags it. BUILD: on ingest, detect a bounce/DSN (from mailer-daemon@/postmaster@, or Content-Type multipart/report; report-type=delivery-status; parse the Original-Message-ID / In-Reply-To / the failed recipient), find our outbound comms_messages row by that Message-ID, and mark it delivery_failed (new nullable column or a comms_items flag) so the thread shows "⚠ delivery failed to X" instead of the operator having to spot the bounce email. Guardrails: only flag on a genuine hard bounce (5xx), not a vacation auto-reply or a soft/transient 4xx retry.
Out-of-office / scheduled auto-responder (dates, weekends, custom windows) — AI-optional
Chad, from the email-sending kickoff (deferred to Phase 2, now unblocked). An OUT-OF-OFFICE style auto-responder that fires on ANY inbound client email while a chosen window is active — distinct from the per-message "delayed responder" below (which fires N hours after a specific unanswered message); build the two on ONE shared auto-responder framework. MODES: manual on/off ("I am out now"); a scheduled DATE RANGE (specific start/end dates); WEEKENDS ONLY; and recurring windows. Each mode has its own message, and the body can OPTIONALLY be AI-generated (reuse generateText / lib/llm.ts) or a fixed template. SETTINGS: per-person and/or company-wide, the mode + dates/weekdays, the message or AI prompt, on/off. SENDS through the now-built path: POST /api/comms/send + sendEmailReply (Gmail-API send as the From mailbox, records the outbound, dedupes on our Message-ID). SCHEDULING infra already exists to reuse: lib/automation-schedule.ts models one-shot dates, weekly-by-weekday (→ weekends), date ranges, run-hour + catch-up; guardCron + CRON_SECRET (lib/api-guard.ts) + the claim-row-before-work idempotency (automation_runs unique index) give once-only sends. TRIGGER off the inbound path (/api/comms/poll → ingest). ⚠️ GUARDRAILS: once per sender per window (never spam a client who emails twice), never on ignored/internal/unmatched threads, clear "this is an automated out-of-office reply" labelling, a kill switch, and it must NOT mark the client message replied. Company-wide default signature + the From picker already exist to reuse.
Auto-fire enrichment on new client message + "Generate reply with AI" on threads
Chad, 2026-08-11, near-term AI-comms adds on top of the per-thread/per-meeting enrichment that shipped (Generate/Regenerate buttons + editable prompts in Settings). (1) AUTO-FIRE: when a new client message lands on a thread (via /api/comms/poll ingest), automatically run enrichThread(threadId) for that one thread so enrichment stays current without a human clicking, instead of a big catch-up batch. Start button-only, flip this on once the output is trusted. (2) GENERATE REPLY WITH AI: next to the Mark-replied controls on a thread, a button that opens the existing AiGeneratePopup pre-selected with that thread (or a single message) as context and output shape draft_reply. Since there is no field to insert into, give the popup a "show result in a copyable field" mode (it currently auto-inserts+closes) so the drafted reply can be copied into the email, then the human marks replied. lib/comms-enrich.ts (enrichThread/enrichMeeting) and the popup/pickers already exist to build on.
Client Activity view — a per-client feed of everything the rundown measures (tab and/or pop-up)
Chad, 2026-08-06. The daily rundown checks each client for ACTIVITY and reports "no activity for N days" when there is none, but the activity itself is invisible: you cannot see WHAT happened. Surface it as a per-client Activity feed in plain language, e.g. "Email sent to client", "Client sent us an email", "Catherine started this task", "Corrine edited this initiative", "New initiative added". The source of truth for what counts is getLastActivityByClient in lib/daily-rundown.ts (the newest of: a task event — created / edited / completed / status-changed / reassigned / reopened / moved; a client email or Asana message either direction; or an initiative creation), so the feed should union exactly those sources into one time-ordered list with human-readable summaries + actor names. ACCESS (decide placement when building): (a) an Activity TAB on the client pages (the tabbed area, app/[client]/(tabs)), and/or (b) an Activity POP-UP quick-link accessible from anywhere — especially the daily rundown, where a client reading "no activity" (or any activity gap) should click through to the actual feed, ideally as an overlay on top of the current rundown pop-up. Chad leans toward the pop-up being the primary, everywhere-accessible surface, with maybe a tab too. Makes the rundown's activity signal legible instead of a bare count.
The MCP connector exists: decide whether the personal API key follows it
Chad, 2026-07-22, ANSWERED IN PART 2026-10-27. The original question was whether a remote MCP server can carry PER-USER auth, so the API keeps attributing work to the right person. It can, and it now does: /api/mcp authenticates with OAuth (lib/mcp/oauth.ts), every token names one team member, and reads are scoped by that member's permissions exactly as their personal key would be (docs/dot-mcp-handoff.md). ChatGPT and Claude both connect with a URL and a Google sign-in, no key anywhere. WHAT IS LEFT is the other half of the original item: the HEADLESS Claude Code session in a client repo still uses a personal key (components/work/ApiKeys.tsx + docs/production-api-usage.md), because it has no browser to run an OAuth flow in. Decide between (a) leave it: the key is the headless credential, the connector is the interactive one, and say so plainly in Help; (b) teach the repo session to run the OAuth device flow once and store the token, which removes key management for everyone but is real work; or (c) make the key easier to live with (one-click regenerate, a copy-paste env line). Also worth deciding: which further tools the connector should expose (creating a task and advancing a status are the obvious two, both deliberately left out of v1).
AI rundown audit: let a finding be dismissed, and remember it
Chad, 2026-08-25. THE HIGHEST-VALUE FIX for the audit, and the one that decides whether the feature survives contact with daily use. Today migration 130 stores one immutable jsonb blob per run (rundown_ai_checks.analysis) with no accept, no snooze, no "known". So the same findings are re-reported every single day until somebody hand-edits the underlying data — 36 stage-less initiatives, say. Even after the noise-cutting item above lands, whatever is left will still repeat forever. BUILD: dismissing a finding (accepted, or fixed) stops it coming back, keyed on the FACT it points at (the initiative/task id plus the finding type) rather than on the wording, which the model will vary run to run. Follow the pattern already proven for KPI penalties: kpi_penalty_exclusions + loadExcusedTaskIds (lib/kpi-exclusions.ts) — a soft, auditable row carrying who/when/why, nothing deleted, reversible. Then have the audit filter dismissed facts before it reports. Needs a small migration.
Data hygiene: a standing panel, not a daily AI finding
Chad, 2026-08-25. Three counts from prod on 2026-08-25 that the AI rundown audit keeps re-discovering as "findings" when they are really a one-time cleanup plus a standing report: 36 of 64 open initiatives have no pipeline stage; 13 open tasks sit under a COMPLETED initiative; 15 open tasks have no due date. These are fixed, countable facts — a screen with three lists and a link to each object, no model involved, no daily re-litigation. FIX THE STAGE ONE AT SOURCE TOO: it is not only legacy. 20 of those 36 stage-less initiatives were created AFTER the stage feature shipped (migration 113), because initiatives arrive by Asana sync and the sync has no stage to set, so every new initiative is guaranteed to have none until a human touches the strip. Either stamp 'strategy' on sync, or drop the idea of an unset stage entirely so null cannot recur. Do this and the audit item above stops having anything to report in that category. Pairs with the two AI rundown audit items above; that is the whole "the KPIs found real stuff, now let us fix it" thread from 2026-08-25.
Richer hours reporting
Expand /hours into per-person / per-client / per-period / padding / presence sub-views. Deferred until enough hours data accrues.
Client vs internal out-of-spec
Dashboard "quality" should count only client bounces, not internal QA catches. Blocked on capturing from_status on in-app status changes.
Automate completion → month hours
Auto-stamp a dated hours entry when a task completes, so the board's "this month" figure isn't 0 for completed work.
Day-based hours calc
Switch worked-hours from per-task to per-day-span so overlapping tasks aren't double-counted; also show a person's own KPIs atop My Tasks.
Padding monitor
Watch logged hours for padding now that the ledger is readable.
Minimum billable hours on tasks (logged vs billable) (BUILT, on dev for review)
BUILT 2026-09-28 on branch feature/template-library (migration 147), deployed to statusdev for review; delete this item when it ships to main. Chad, 2026-09-28. Long wanted. A task can carry a MINIMUM BILLABLE HOURS floor (e.g. a landing page is at least 6). Also fixes work done off the clock that nobody logged. THREE NUMBERS: logged hours (unchanged: the sum of the work_hours_adjustments ledger, lib/work-hours.ts), the minimum billable hours (a new nullable task column, blank = no floor), and BILLABLE HOURS = max(logged, minimum), only once the task is done (a not-done task gets no floor; 9 logged on a 6 floor bills 9, the floor is never a cap). Billable is COMPUTED at read time, never stored, and NEVER written into the hours ledger as a top-up entry: that would put hours nobody worked into the hours monitoring page (lib/hours.ts, which exists to catch padding), falsify each person's time, and be impossible to tell apart from real work later. WHO USES WHICH (Chad, 2026-09-28): we work on RETAINERS, so billable hours are for managing each client's CAPACITY: they count against the client's retainer (clients.monthly_capacity_hours) and are what we use to decide what fits in a month. They are INTERNAL: we do our best not to show hours to clients today, so nothing client-facing should display them. Keep it possible to show them later (a client has asked before), but not by default. Logged hours stay the honest record for hours monitoring and each person's time. WHERE THE MINIMUM COMES FROM: set by hand in the wizard and the task pop-up (manager-only, its own capability like manage_priority, since it is pricing); carried by task templates; and in PLANS via a new hidden markup attribute (e.g. data-sga-task-min-hours) so ANY plan, template or ad hoc, can set it and the import writes it onto each task. Claude Code suggests a minimum per task while writing a plan (the template's value, else a sensible guess) and the author confirms or skips it: add to the plan-writing guidance, the markup spec (lib/task-markup.ts + /api/plan-markup-spec), and the preview (show each task's minimum; a re-import never overwrites one already set, it shows the difference). The attribute stays hidden so hours never appear in the client-visible text. Automations get it in the SAME change (template editor, lib/automation-fire.ts, template_config plumbing). FORGOTTEN HOURS: keep the signal. A done task well under its minimum gets a quiet note ('logged 1 of a 6-hour minimum') alongside the existing no-hours flag, so forgetting is still noticed while billing stays right.
Move a task or initiative to another company
Chad, 2026-09-28. No way in the app to move work between companies. The Outsourcing SOP initiative and its plan task had to be moved from SGA Training (Testing Client) to SGA by hand in the database: initiatives.client_id set to the new company with sub_client_id / sub_company_id cleared, then tasks.client_id on every task under it (tasks carry their OWN client_id, so moving the initiative alone leaves its tasks behind). Rare, but it should not need the database. BUILD: a Move action on the initiative (takes all its tasks) and on a task (pick an initiative in the new company to land in). Also move the rows that point at the initiative and carry a client_id: deliverables, context_items, work_reports, activity_events. Separate from the template library: moving is not copying.
Task due dates: optional time-of-day (default "by end of day") + timezone display
Chad, 2026-08-19. BIG ONE — its own stream, not a quick fix. tasks.due_on is a DATE (schema.sql:196) and roughly 60 sites compare it as a lexical YYYY-MM-DD string, including server-side SQL filters (lib/data.ts:1025,1127; lib/kpis.ts:427-431,1144-1147), exact-equality day matching on the dashboard due-today strip (app/dashboard/page.tsx:655-674), the forfeit SQL (migration 100:31-32), and committed_due_on for Timing. SAFE DESIGN: add a separate nullable due_time column and NEVER convert due_on to timestamptz — that keeps every string compare and SQL filter working untouched. UI: DateField gains a "by end of day" checkbox (checked by default); unchecking reveals a time picker with the timezone shown (lib/sga-time.ts already has wallTimeToUtc/sgaWallTimeToUtc, SGA_TIME_ZONE, MEETING_TIME_ZONES). Overdue logic: the only semantic change lives in lib/overdue.ts isTaskOverdue (a "due today but the hour has passed" branch) + its tests. Display: ~14 sites print due_on raw; introduce ONE shared formatDue(due_on, due_time) helper (fmtDue/fmtDate are currently duplicated per file). PREREQ worth doing first: DateField adoption is incomplete — CreateTaskWizard (:522), CreateInitiative, InitiativeDates, EditInitiative etc. still use raw <input type="date">, so finish that migration or two different date UIs ship with different capabilities. Open question: Asana has due_at but lib/asana-client.ts:81 never requests it, so a time set here would not round-trip. Multi-day; needs its own changelog entry and dev-first testing (new column = migration).
Consolidate the remaining task pop-ups into the shared CreateTaskWizard (phase 2)
Chad, 2026-08-13: "stop having so many pop-ups — shared assets, so we update one and it updates everywhere, and hide fields instead of duplicating." PHASE 1 SHIPPED this session: FollowUpFlow now hands its task creation to the shared CreateTaskWizard (via a new `followUp` prop) instead of its own duplicate form — which is what killed the stale always-on Start date it still showed — and the dead AddTaskModal.tsx was deleted. WHAT REMAINS (phase 2): two more task pop-ups still re-implement the task fieldset by hand and POST to /api/asana/subtask themselves, so they can drift again: (1) components/work/BlockedFollowUpModal.tsx — the blocked-driven follow-up (name/description/owner/status/phase/where/Start/Due/Hours + "Top priority"), opened from WorkMode/QuickLook/TaskRow/TaskDetail/EditInitiative when a Blocked value is set; it ALSO runs a dependency mini-wizard, and for the on_hold case Start is the required wake date, so its extra steps stay but the task FIELDS should come from CreateTaskWizard. (2) components/work/StatusFollowUpModal.tsx — the QA/status-driven follow-up; already minimal (owner + Top priority, auto title/due), so lowest value to touch, but should still route through the shared wizard for consistency. APPROACH: same shape as phase 1 — keep each modal's pre-steps, pass their extra intent through CreateTaskWizard props (extend the `followUp` prop, or add a small config for blocked/QA), and add hide-field props to CreateTaskWizard so a context can drop steps it does not need (e.g. surface Start only when it means a wake date). Do them ONE AT A TIME (Chad's call, to keep each low-risk). End state: ONE task-create surface; TaskDetail.tsx remains the separate EDIT pop-up. Note the initiative side is already single-source (CreateInitiative for create, EditInitiative inline chips) — no initiative-pop-up consolidation needed.
Internal view = an auth-gated "Switch to internal view" in the review tool (SGA emails only)
Chad, 2026-07-20 — redefine what "internal view" means for a plan. Today it is a ?internal=1 URL param that anyone with the link can flip; it widens the plan and shows the imports-as fields (owner, dates, dependencies, description) beside the client-facing content. That is a preview hack, not access control. What it SHOULD be: when a logged-in Strategic Growth Architect teammate (a VERIFIED @strategicgrowtharchitect.com Status session) views a plan through the review tool, they get a top bar "Switch to internal view"; clients and anyone not signed in as SGA never see the bar or the internal fields. Build on the review tool's existing auth: /api/review-ui/whoami already identifies SGA teammates from their Status session, and internal deliverables/comments already require a verified session (see docs/design-review-tool-migration.md). The render already exists — lib/plan-internal.ts internalReviewHtml() parses a plan's data-sga-* markup and produces the imports-as review — so the review tool fetches the plan HTML and, for an SGA-authed viewer, offers the toggle that runs it. Retire ?internal=1 as the ACCESS mechanism (keep it only as the render mode behind the gate). Ties to the template previews too, which currently use the same guessable param.
Report templates (monthly + initiative) — the other half of the template library
SHIPPED 2026-07-20: /templates (was /import) is a template library — monthly + initiative plans, each in full / work-only / hybrid, with per-template Markdown guides and live examples. NOT YET BUILT: the REPORT templates the library is designed to hold. Chad, 2026-07-20: a report is authored the same way a plan is — a Claude Code session downloads the template guide AND the report context, asks the operator for the parts Status cannot know, and submits the result back through the API. NOT a generator inside Status. What that needs: (1) report template content — a monthly report, and an initiative wrap-up report for a completed initiative, which does not exist in any form today; (2) a CONTEXT endpoint so a session can pull the month/initiative facts Status already holds (hours, deliverables, versions/revisions, approvals, comments, completed vs in-progress tasks) — today that data is computed by getClientReport in lib/data.ts:1324 and never leaves the page; (3) a home for the report shape — buildMarkdown() is hardcoded inside app/[client]/(tabs)/report/page.tsx:32, so there is no lib to build on. ⚠️ Reports are ALREADY modelled three different ways: the /{client}/report page, a hand-built artifact in Cowork pasted back as reports.report_url, and at least one report living as a deliverable on its own initiative ("June 2026 Report"). Pick one before building, or this adds a fourth.
Revisit the import_plans capability now that /templates covers reports
/templates is still gated by import_plans, inherited from when the page was the plan-import guide. Writing a monthly report is not importing a plan, so once the report templates land the set of people who need this page is wider than the capability name implies. Either rename the capability (it is declared in lib/comms.ts:66, listed at :75, defaulted at :82, and labelled in components/work/PermissionsEditor.tsx:34 — all four must move together) or split viewing templates from running an import. Deliberately NOT widened as part of the template library ship, so nobody silently gained access.
Colour-code task cards on the board by activity
Chad, 2026-07-19: a board card should show at a glance whether the task is being worked on — in progress green, paused yellow, not started white, overdue red. NOW POSSIBLE: "paused" only became a distinguishable state on 2026-07-19 (migration 083 added work_sessions.close_reason, and it was backfilled, so historical sessions read correctly too — this is NOT going-forward-only). SHAPE: one actor-agnostic query per board load — work_sessions select task_id, started_at, finished_at, close_reason for the board's task ids, ordered started_at desc, reduced in JS (first row per task_id is newest: no finished_at → running; close_reason paused → paused). That single query subsumes the need for an all-actor variant of getRunningTimerTaskIds. Add a (task_id, started_at desc) index. Then a taskCardTone() helper next to statusTag() in lib/status-flow.ts, a timer field on the board card in app/[client]/(tabs)/page.tsx, and swap the hardcoded bg-white at components/work/ClientBoard.tsx:316. No select changes needed — getInitiativeTasks is select(*), so status/due/blocked are already on the card path. DECISIONS FIRST (each changes the result): (1) COLOUR CLASH — the app already means BLUE by "in progress" and GREEN by "ready to send" (statusTag, lib/status-flow.ts:115-135); green-for-in-progress puts two meanings on green. (2) AMBER IS TAKEN TWICE on the same card — blocked and initiative-on-hold badges are both bg-amber-pale; a yellow card sits right next to them. orange-pale would separate paused, or accept that blocked/on-hold/paused are all legitimately "stalled". (3) OVERDUE IS A DIFFERENT AXIS — it is a date fact, activity is an activity fact; colouring the card by one loses the other, and an overdue BADGE already exists at ClientBoard.tsx:346. Suggested: activity owns the background, overdue gets a red left border (mirroring the initiative blue rail) so both read at once. (4) Initiatives share the lanes — leave them white (colour = a task's activity) or define a rollup. Half a day, mostly the palette decision.
Refine DB initiative plans (v1 shipped)
v1 is live: importing a monthly plan gives each initiative its own DB plan — a public /db-plans/<id> page generated from the initiative + tasks (client view + data-sga-task-* markup), attached as its plan_deliverable so it has View plan / Import right away, and a starting point to expand into a full plan in Claude Code. To refine: tasks/descriptions currently link the source monthly plan (should be able to point at the initiative's own DB plan); re-importing a DB plan is a no-op until a full plan is written (and would dup manually-added tasks with no import_key); add a "Generate v1 plan" button for initiatives created without one; stage strip + full plan authoring. Only matters at month start.
Server changes that do not redraw the page
A class of small bugs, one of which was fixed on 2026-07-19 (starting a timer set the task to In progress but My Tasks never re-rendered, so the card sat under Next Up with a running timer until you reloaded). Two more sites have the same shape and are still unfixed: WorkMode pause() writes the session and logs the hours without a softRefresh, and components/work/MyTaskStatus.tsx (the override select) POSTs the status and only updates its own local value. Compare GuidedStatus.tsx, which does it correctly. Worth one pass over every fetch that mutates server state to check it is followed by softRefresh(router).
"I need a question answered first"
A task action that blocks the task and spawns an info-request task assigned to someone else (like the QA follow-up, but for questions).
My Tasks complete-permission gap
My Tasks completes via a different inline control that isn't permission-gated the way the board is. Decide whether to gate it and add "Send to QA" there.
Share buttons on Team / My-Tasks pop-ups
The share deep-link is hidden there (no single client slug in scope); enable it by adding a per-task client_slug.
Full View retirement
Commented out of the sidebar but the page/route still exist — and it is STILL REACHABLE: middleware.ts has no guard for /full-view, so any signed-in SGA user with the URL or an old bookmark (docs/sop-sga-status.md still deep-links it) gets the full page. Hidden, not retired. Scoped 2026-07-19 — DELETE: app/full-view/page.tsx (793 lines), app/full-view/loading.tsx (6), components/work/FullViewFilters.tsx (139, used only by that page); nothing else imports any of them. EDIT: components/Sidebar.tsx in 7 places (the SubmenuKey type union line 28, the SUBMENU_PARENT map entry, the commented-out nav entry, the FULLVIEW_TABS array, NON_HOME, the fullviewActive() helper, and the `if (key === 'fullview')` submenu branch) + components/AppShell.tsx line 47 (drop '/full-view' from hiddenNav). The type union is why this needs a real typecheck, not a blind delete. THEN: four lib/data.ts exports become dead code (getMonthRollup, getUnscheduledTasks, getCompletedTasksForMonth, getOverdueTasks — Full View is their only caller), and ~10 doc references go stale (README, docs/overview.md, docs/sop-sga-status.md, docs/deploy.md, docs/architecture-and-data-model.md). Note docs/handover-2026-06-18.md pairs the per-row overdue rule with Full View's Overdue group; that note goes stale too. Its own PR — an hour, mechanical.
Upload QA wizard sign-off
The guided self-QA + per-version-hours upload flow is built but awaiting approval to turn on.
Seed the QA follow-up task with the Upload QA checklist
The Upload QA config in Settings (categories -> subcategories -> line-separated questions, stored in upload_qa_categories / upload_qa_subcategories / upload_qa_questions, read by lib/upload-qa.ts) is only used today for SUBMITTING work — the uploader self-QAs against it. It should also drive the QA task that gets created when work is submitted for review: when a follow-up QA task is created (StatusFollowUpModal -> createTask), look up the questions for the submission's subcategory and write them into the new task as its CHECKLIST, so the QA'er works the real list instead of an empty task. Plumbing already exists — lib/create-task.ts takes a `checklist` param — so the work is choosing which subcategory a QA task inherits from (the deliverable's? the submission's?) and whether the universal questions (upload_qa_universal) are prepended. Chad, 2026-07-20: backlog, not urgent.
Submit: attach a deliverable to a TASK, not just an initiative
Chad, 2026-07-19, testing the submit flow: he could attach a submitted deliverable to an INITIATIVE but not to a task - "we need one more level there". He worked around it by linking on /deliverables afterwards, which is the wrong place to have to go. WHY IT HAPPENS: the wizard takes taskId only from CONTEXT mode (launched from a task card), where context.taskId is already known - components/work/UploadQAWizard.tsx. On the standalone /submit page there is no context, so no task is ever chosen and lib/upload-qa.ts attaches the deliverable to target.initiativeId only. WHAT TO BUILD: after the initiative is picked on the standalone page, offer that initiative TASKS as an optional third level, and pass it as taskId so attachDeliverable links the task (which auto-links its initiative at the same role, so nothing is lost). The role picker already exists in the wizard and is only shown in context mode - it should show whenever a task is chosen. NOTE the pieces already exist: /api/work/client-initiative-options returns initiatives, and getAttachTargets in lib/deliverables-explorer.ts already returns tasks narrowed to an initiative for exactly this kind of picker. Reuse rather than writing a third one.
Make the API self-describing, so the house rules stop needing field-by-field docs
Chad, 2026-07-19: "most of what it is downloading is context... they have to ship descriptions for what they are, so it can make its own context. It doesn't have to know what every variable name is." He is right, and there is already precedent in our own API: GET /api/initiative/{id} ships a task_import block - the data-sga-task-* contract embedded IN the payload - precisely so a session does not have to go read external docs. That never became the pattern. WHY IT MATTERS: every field we add currently owes a house-rules edit and a re-sync, which is most of what accumulates in docs/house-rules-updates-owed.md. If each block in the payload carries a line saying what it is, adding a field means sessions just see more context - no doc edit, no drift. WHERE IT DOES NOT REACH, and this is the important half: (1) WRITES still need an exact contract. A session submitting a deliverable must send precise field names in a precise shape - a description saying "the deliverable name" does not tell you the field is `name` and not `title`, which failed exactly that way on 2026-07-19. Reading is forgiving; constructing a valid request is not. (2) RULES ARE NOT FIELDS. "A deliverable is worked on by one task at a time" and "carrying as working_on detaches the source" cannot be expressed as a field description; they are behaviours, and they stay house-rules content - but they change rarely, which is the point. SO THE SPLIT IS: reads self-describe, writes keep a contract, rules stay in the house rules. Much smaller surface to keep in sync. ALSO WORTH BUILDING: a way to PREVIEW the payload, so Chad can judge whether a session would understand it rather than reading a doc that claims what the API does - the two cannot drift if there is only one of them. DO THIS BEFORE the batched house-rules pass: it changes what that pass should even contain.
API update for submitting deliverables — SHARED project with sga-house-rules
RE-CHECKED 2026-07-20 — two of the five sub-points have SHIPPED and are struck from this item. Still needs a joint design pass across BOTH repos before the rest is built: a worker repo sending fields Status ignores is worse than not sending them. POST /api/submit is how a headless Claude Code session registers a deliverable; documented in docs/production-api-usage.md here and mirrored verbatim into sga-house-rules/technical/production-api-usage.md. DONE: accepting a deliverable ID (lib/repo-api.ts resolves a uuid as the row id with slug as fallback, and rejects an unmatched uuid rather than minting a junk row) — which was the one that mattered, because a deliverable is now workable from a task in a different initiative and slug+initiative no longer identifies one. Also DONE: `type` now writes to review_type instead of `kind`. WHAT REMAINS: (1) THE NAME IS STILL LOST — registerDeliverable still falls back to label = slug when `name` is omitted, so a submit without a name produces a machine-shaped label. (2) A submitting session still cannot set description, category, subcategory or tags[] — RegisterDeliverableInput is still only initiative/deliverable_slug/name/url/type/version, while all four have been columns since migration 085 and are editable in the UI. (3) Versions have a real author field now, and the submit path is where the author is actually known, but registerDeliverable calls syncDeliverableVersions without a createdBy — so submitted versions are still anonymous even though VersionContext supports it. Full notes: docs/house-rules-updates-owed.md.
⚠ files.sga.team IS GONE — retire the SFTP publish path, and decide about 68 dead deliverable links
Chad, 2026-09-17: "The SFTP is dead." It is worse than that, and this is the part to read first: THE WHOLE HOST IS GONE, not just the SFTP service. Verified the same day — files.sga.team resolves in DNS (2.25.160.135) but nothing answers on port 80 or 443. Anything that merely LINKED to that host is therefore dead too, which contradicts the reasonable assumption that links keep working. || CLIENT-FACING CONSEQUENCE, decide this separately and first: 68 deliverables point at URLs on that host. MEASURED FROM THE CLIENT-FACING REVIEW API on 2026-09-17 (not from row counts, which overstate it — a review_url on a row does not mean the review renders it): 34 deliverable VERSIONS across 8 live reviews will not load, and FOUR of those reviews have nothing else on them at all — SBMFRx business cards 8 broken / 0 fine, eChartPro marketing assets 8/1, SBMF blood drive materials 6/4, ChartPro conference follow-up 4/7, Oral microbiome mastery 3/0, Crome Group website 2/6, Free course opt-in page 2/0, Classic platform build 1/0. The review links themselves still open; it is the deliverable inside that is gone. All were created 2026-06-04 to 2026-07-17; nothing since mid-July uses the host (120 deliverables in Aug + Sep all point elsewhere), so this is historical work, not current. A client opening one of those older reviews today sees a deliverable that will not load. THE OPTIONS: leave them dead as history; re-publish those files somewhere live (needs the source files); or mark those reviews archived so nobody opens them expecting content. Chad has not chosen — do not pick for him. || WHAT WORK ARRIVES HOW NOW (Chad, 2026-09-17): from a company repo via POST /api/submit with a URL, or as a vendor / public-test hand-in stored as a work_attachment in Vercel Blob. Nothing needs to write files anywhere. Removing the SFTP path therefore costs NO capability: a deliverable is already creatable from a bare URL through three live paths (POST /api/deliverables, POST /api/submit → registerDeliverable, POST /api/upload-qa/submit → recordSubmission). || DONE 2026-09-17 as the stop-gap: 'Submit' was taken OFF the sidebar (components/Sidebar.tsx, the line is commented with a do-not-re-add note) because its wizard published to that host and was a dead end. The page and route still exist, untouched. Also already done: the 'Company files' browser is gone from 'Add link' (it listed folders over SFTP; see the changelog). || THE REMOVAL MAP, audited 2026-09-17 so nobody has to re-derive it. DELETE ENTIRELY: app/api/files/upload/route.ts (pure SFTP write, ZERO callers), app/api/files/list/route.ts (one caller, below), scripts/sftp.mjs, scripts/reorg-host.mjs, scripts/version-hubs.mjs, scripts/upload-platform-build.mjs, scripts/migrate-classic-deliverables.mjs (the last three are one-shot migrations already run), types/ssh2-sftp-client.d.ts, docs/agent-upload-guide.md (the whole document is 'connect over SFTP' instructions for agents and is now actively misleading). Drop ssh2-sftp-client from package.json and the serverExternalPackages line in next.config.ts (check whether that is the config's only content). EDIT: lib/files-sftp.ts — DO THIS FIRST AND ON ITS OWN, it is the one trap: requireSession() lives in that file and has nothing to do with SFTP, and THREE healthy routes import only that (app/api/files/initiatives, app/api/upload-qa/config, app/api/upload-qa/submit — the last two are the QA taxonomy, which is fine and used by Settings AND the outsider flow). Move it to lib/session.ts or point those three at guardSession from lib/api-guard, THEN the file deletes cleanly. Also: components/work/UploadQAWizard.tsx — delete listDirs() and its three call sites; note the month branch there is already unreachable dead code (MONTH_SECTIONS can never match the offered SECTIONS). Stale comments to fix: app/api/upload-qa/submit/route.ts:1, lib/upload-qa.ts, app/api/deliverables/options/route.ts:10, app/api/files/initiatives/route.ts:3. Docs: drop the SFTP_* row from docs/RESTORE.md's secret inventory and from backup-restore.html, and the SFTP_* entries in the secrets narrative in this file. LEAVE ALONE (never used SFTP): /submit's page shell, AddDeliverableModal, the whole outsider hand-in (Vercel Blob), the attachment proxy, the kit builder, the Assets tab and clients.files_url (plain <a href> — they now point at a dead host, which is the decision above, not a code bug). ENV: SFTP_HOST/PORT/USER/PASSWORD (plus the typo fallbacks SFTF_USER and SFTP_PASS) are read ONLY by lib/files-sftp.ts and those five scripts. || FOLD IN WHILE THERE: once listDirs goes, the wizard's 'which deliverable is this a version of?' dropdown offers only 'Create new…', so adding a second version means retyping the name exactly or forking the deliverable into two rows — which has happened before from a slightly different spelling. Source that dropdown from the deliverables table scoped to the initiative, which is what the item below already asks for.
Submit page: pick a real deliverable, and ask for a description
RE-CHECKED 2026-07-20 — this was a six-part rework and FOUR PARTS HAVE SHIPPED, so it is now down to two things. SHIPPED: the naming bug (the wizard kept the typed name and derives the slug separately); link role (working_on vs reference); upload_submission_id is wired, so the hours and QA answers finally attach to the version they belong to; `type` reaches review_type; and beyond the original scope, a parent picker, an explicit "add to review" toggle and config-driven plan attachment. Category and subcategory are also carried now — deliberately from the step-1 QA type rather than asked twice. WHAT IS ACTUALLY LEFT, both in components/work/UploadQAWizard.tsx: (1) THE DELIVERABLE DROPDOWN STILL LISTS SFTP DIRECTORY NAMES, not deliverables — it calls listDirs() on the initiative folder, so the wizard cannot see the deliverables table as entities and cannot offer "add a version to this existing deliverable", which is the single most common thing someone submitting actually wants. It should pick from real deliverables, scoped to the initiative. (2) NO DESCRIPTION FIELD — a column since migration 085, still only settable retroactively on /deliverables, which nobody will do. Tags are deliberately absent pending the managed-vocabulary item below. PAIRS WITH the /submit API item above — the page and the API should land on the same contract, not two. ORIGINAL FRAMING, kept because it is still the reason this matters — Chad, 2026-07-19: "that whole submit page probably needs to be updated to this new deliverables standard."
A managed tag vocabulary — no hand-typed tags
Chad, 2026-07-19, after testing submit: "You shouldn't just be able to hand-type tags." He is right — free text is close to unsearchable because everyone writes it slightly differently, which is why the same reasoning removed the free-text "what is it?" field from the wizard at the same time. Tags were REMOVED from the Submit page rather than left as a typed box; a picker over an empty vocabulary would be theatre, so they come back once there is a list to pick from. CORRECTED 2026-07-20: this used to say "/deliverables still has a free-text tag box" — no longer true, DeliverablesExplorer removed it for the same reason, leaving only filter chips over live values (which is fine). So there is now NOWHERE to add a tag, by design, until there is a vocabulary to pick from. WHAT TO BUILD: a small managed list (probably a `tags` table, or Settings-managed app_config) with create/rename/retire, then a multi-select on both the wizard and the explorer that picks from it. Values are already normalised through the canonical slug rule on write so casing variants cannot fork a tag, but that only stops "Landing Page" vs "landing-page" — it cannot stop "landing" vs "landing-page" vs "lp", which is exactly the problem a managed list solves. DEPENDS ON the Stage 4 category taxonomy decision, since the two vocabularies should be designed together rather than one at a time.
SCHEDULED 2026-08-02 — drop the deprecated deliverables.kind column
DONE 2026-07-19 except the drop itself, which waits alongside the versions jsonb so both rollback paths retire together. `kind` was the junk drawer among the four fields that all tried to say "what kind of thing is this": it held 131 page, 13 null, 5 responsive, 1 proof and 1 internal. The schema claims page|pdf|image|link and none of pdf/image/link was ever written; `asset` was never written either, despite being the one distinction the column existed to make. The strays came from registerDeliverable doing kind: input.type while BOTH lib/reviews paths wrote that same value to review_type. FIXED: migration 092 moved the 5 rescuable review types into review_type (only where it was empty, so nothing real was overwritten); registerDeliverable now writes review_type like every other path; the per-client Deliverables tab shows category instead of a tag that read "page" on 137 of 150 rows; lib/types.ts marks kind deprecated and models the real columns. WHAT IS LEFT: alter table deliverables drop column kind — grep that nothing reads it first. THE SURVIVING THREE ARE CORRECT AND SHOULD STAY THREE: category/subcategory = what it IS (fed by the Submit wizard step-1 type, which also picks the self-QA questions, so it is one question asked once); review_type = how the review tool RENDERS it; section = where it is filed, now derived from the type rather than asked separately. RESOLVED 2026-07-19 - the one ambiguous row. "June 2026 Report" is the PeakSpan June monthly report, on an initiative called "June Report", hosted at a client-facing reports URL, in_review true, v2. So visibility=public was correct: the stray internal was cleared out of kind and visibility was left alone. WORTH KNOWING WHAT THAT RULED OUT: visibility=internal means team-only in BOTH the client report and the review at once, so a "hidden in the review but still visible to the client" state does not exist in this model. If that is ever wanted it needs a SECOND flag and is a feature, not a fix. kind is now 138 page + 13 null, with no stray values left.
Group the review page by parent deliverable
FOLLOW-UP to sub-deliverables, which SHIPPED 2026-07-19 (migration 089: deliverables.parent_id, ONE LEVEL DEEP enforced by the deliverables_parent_one_level trigger, deliberately not backfilled; settable from /submit via the wizard's parent picker). Children are full deliverables with their own versions, review and permalink — the parent only groups them. The original item said explicitly: DO NOT BUILD THE REVIEW-SIDE GROUPING FIRST, because there was zero data saying which deliverable is a child of which. That is still the position — the gate is DATA, not code. Let people set parents on real work from /deliverables and /submit, then check whether enough of it exists to be worth rendering. THE PATTERN IS REAL (confirmed against live data 2026-07-19): every initiative with 4+ deliverables contains 2+ clusters — Healthy Spine for Life has Webinar Registration + Confirmation, Sales Page + Sales Page No Menu; ChartPro has Full Report PDF / One Page PDF / Web Report. Note the FOUR distinct relationships in that data (component-of, variant-of, format-of, pages-of-one-site) — parent_id flattens all four into one, which was accepted deliberately; if the review page ever needs to render them differently, that is a second field, not a deeper tree. Pages-of-one-site probably should NOT use a parent at all: the initiative is already that container.
SCHEDULED 2026-08-02 — drop the deliverables.versions jsonb
DO NOT DO THIS BEFORE 2 AUGUST 2026. Stage 2 flipped every reader onto deliverable_versions on 2026-07-19, but all three write paths still DUAL-WRITE the old `versions` jsonb, and it is deliberately kept as the rollback path — if anything about the new table turns out wrong, the jsonb still holds the original history and reverting is a code change rather than a data recovery. Chad, 2026-07-19: leave it a week or two, then drop it, and date it so it does not get forgotten. BEFORE DROPPING, re-run the drift check in docs/deliverables-db-changes.md (every jsonb entry must still have a matching version row — it was 144 entries / 0 missing at backfill). If it drifted, something is writing one and not the other, and THAT is the bug to fix rather than dropping the evidence. THEN: remove the jsonb write from syncDeliverableVersions callers in lib/reviews.ts (saveDeliverables, upsertReviewDeliverable) and lib/repo-api.ts (registerDeliverable), drop `versions` from buildDeliverableRow, remove readVersions from lib/deliverables.ts, drop the column, and take `versions` off the Deliverable type. Once the column is gone the rollback path is gone with it — so take a backup of the column first: create table deliverables_versions_backup as select id, versions from deliverables.
Deliverables + Split polish in the initiative pop-up
Minor follow-ups from the collapsible-sections pass: the pop-up's Deliverables section has no in-place "+ Add" (add lives elsewhere) — consider a persistent add action; and Split initiative currently sits inside the Plan & Review section rather than as its own structural action. Decide placement. Independent of the model rework above — can ship any time.
Flag a message that arrived late but is dated in the past
Chad, 2026-07-21, after the Asana description-edit fix: when a message lands but its own date is well in the past, it sorts mid-thread and can be missed. Idea: a small inline note (or thread flag) like "Arrived just now, originally dated 18h ago" to prompt a look. Cheap in principle: comms_messages already stores created_at (when we ingested it) alongside sent_at (the message's own date), so a large created_at-minus-sent_at gap is the trigger, no new column needed. LIKELY LOW VALUE though: live channels already ingest within minutes (email near real time), and the description-edit case is now timestamped at DETECTION time so it always lands at the end. The only time this fires is a backfill (a newly connected mailbox pulling old history, where it would banner dozens of rows at once, i.e. noise) or a genuinely delayed message, both rare. Logged so the idea is not lost, not prioritized.
KPIs / metrics on the Communications page
The Communications page has no metrics of its own. It needs them — and it's the right home for the response-recency pair that used to sit on the client header ("Since they messaged" / "Since we messaged", i.e. time since the last inbound and last outbound message). Those two came off the client header AND the dashboard's client cards on 2026-07-18, when both were cut to the company KPI trio (Communication · Quality · Timing) scoped per client — three KPIs, because more than that and people ignore them. Recency is a comms measure, not a client-health measure, so rebuild it here alongside whatever else belongs (median time-to-first-reply, threads awaiting our reply, unlinked/unmatched thread count). The data already exists: getClientCommsRecency in lib/data.ts (still used by /dashboard) returns lastInbound/lastOutbound per client. Decide first what the comms team should actually be held to, then build the tiles.
Communication thread on the initiative card + "none attached" warning
An initiative wants a communication thread attached — that comms history is important context for the working repo. On the initiative card (this is where we work from board view), the linked thread isn't shown at all today. Put it on the same action row as View plan / Create-Edit review / deliverables / Split: when a thread is attached, show an "Open communication thread" button; when none is attached, show a warning in its place — "No communication thread attached". Consider surfacing the same "no communication attached" flag wherever initiatives appear, to prompt attaching one.
One-consent Gmail connect
Make Google login also request Gmail (read + offline) and save the connection in the same consent, so staff don't log into Google twice.
Automated enrichment through OpenRouter, with a new prompt
Chad, 2026-07-22. Enrichment is still MANUAL hand-passes (a person or an agent reads the queue and writes summaries/items straight to the DB — that is how the July-22 backlog got cleared). It last ran automatically July 14; between then and the hand-pass, 325 messages had no one-line summary — the scan layer quietly rotting. BUILD: a server-side job that drains the enrichment queue on a schedule with NO human in the loop. Pipe it through OpenRouter (one key, model-agnostic, easy to swap models as they improve) rather than pinning one provider. WHAT THE PROMPT MUST DO, and ONLY this — the boundaries were hard-won: (1) a one-line summary on EVERY message (comms_messages.email_summary — the scan layer); (2) a 2-4 sentence thread summary (comms_threads.summary); (3) owner-tagged items — questions/actions/decisions/dates (comms_items, with owner us|client); (4) a suggested next reply where one is owed (draft_text/draft_status). It must NOT write attention — that is deterministic now and enrichment writing it is what put 15 threads wrong (the ingest endpoint already ignores an attention field; keep it that way). See docs/comms-hub/Comms-Hub-Architecture.md §5 for the full ruleset and Enrichment-Approach-Decision.md for the per-pass learnings the prompt should encode (last-responder rule, deferred actions, dates-from-structured-fields-first, do-not-enrich-noise). QUEUE MECHANISM already exists: getEnrichmentQueue() (enriched_at IS NULL OR updated_at > enriched_at) + markThreadEnriched(); the job reads the queue, enriches, stamps. TWO QUEUES — threads AND meetings (comms_meetings) — the cron must drain both or standups sit un-summarized. WRITE-BACK is the existing POST /api/comms/ingest (COMMS_SECRET), so the job is a consumer of the same contract, not a new path. NEEDS: an OPENROUTER_API_KEY server-side, a cron entry (vercel.json, like the poll), and the prompt itself authored and version-controlled. DECIDE model + cost ceiling before enabling.
Comms comments cutover
Retire the older work_comments ingest now that the timeline reads comms_messages; needs a backfill.
Threads wrongly flagged internal
Some real client threads (with external inbound messages) get flagged internal=true at the THREAD level during ingest/enrichment, which hid them from the client card and the unlinked banner. Worked around by trusting the message-level internal flag; the ingest classification itself should be investigated and fixed at source.
User inbox on My Tasks
A per-user inbox of messages directed at them (probably needs @-mention routing). Scope needs discussion.
Meeting transcripts + client history feed
Log and enrich meeting transcripts into client threads, and build a flat per-client chronological feed.
Tools reference list
An "available tools and what they're for" list (Google AI, Claude Code, Midjourney, stock photos, Canva) on the /help page.
Populate client repo URLs for /help
The /help SOP page is built AND linked in nav (the old "not linked" note was stale — removed 2026-07-19). What is actually outstanding is data, not code: a client with no repo URL is hidden from the help folder tree (lib/types.ts), so the repo URLs need filling in per client. Chad's sign-off on the page content is the other half.
Repo-integration API
The deliverable-centric, slug-keyed integration API is partly built on its branch; continue it.
Reassign guardrail /start handoff
The reassign-with-dependency guardrail is live; the piece where a /start session reads a task's dependencies for context is still pending.