Rule 45/61 (ROADMAP §4): every release entry records version + date + changes + migrations + risks + tests + rollback. Entries describe what the code does — nothing here is planned work (Rule 75). Dates are the commit dates.
Everything below is committed, typecheck/build/test-green, and verified where noted. Production runs rc.1's state plus the mail/deliverability fixes (Message-ID domain, brand chrome) already deployed live on 2026-09-18; this entry covers the work since.
app/src/lib/ai/gateway.ts, ops mirror in
scripts/lib/ai-gateway.mjs) — Gemini, Groq, OpenRouter, Ollama, Cloudflare,
and LLM7 called exclusively on free tiers. Model ids are never hardcoded:
each provider's live catalog is fetched and filtered by its own price/tier
metadata (6h cache), so a model going paid drops out and a newly-free model
joins without a deploy. LLM7 auto-activates the day it lists a zero-priced
model. All-fail → null; the product never error-walls on AI.POST /api/v1/assistant (auth, per-user Postgres rate limit 20/5min,
workspace name/plan injected server-side, prompt forbidden from inventing
metrics). Conversation memory lives server-side in Postgres
(assistant_messages, migration 0026, lib/assistant-store.ts): the
client sends only the question and the server's stored thread is the sole
context — client-supplied history is ignored, so a hand-edited request
cannot inject turns. The thread follows the user across devices and browser
profiles, restores via GET /api/v1/assistant, clears via DELETE (the
widget's Clear control), is retained at 40 turns / 180 days with prune on
read plus the nightly cron, and is purged with the account (FK CASCADE).
Verified live in a browser session (gemini free tier) and via the
rate-limit bucket in Postgres.0025_ai_usage_events.sql) — every
gateway attempt (both mirrors) records purpose/provider/model/chain-rank/
ok/latency/error; the weekly health email gains a "Last 7 days" table
showing how the fallback chain distributes load (primary vs fallback
successes, failure counts, p50 latency). Best-effort writes; 90-day
retention via prune_ai_usage() in the nightly prune cron. Verified
end-to-end live (digest AI calls → rows → emailed rollup).0024_finding_explanations.sql,
staleness keys in 0027_explanation_staleness.sql) —
"Explain in plain English" on each visibility-report finding;
POST /api/v1/visibility/findings/[id]/explain builds a facts-only prompt
(severity, headline, detail, points at stake, related check evidence),
sanitizes (markdown stripped, 700-char cap), and caches the result on the
finding row with provider stamped. Org-scoped by JOIN, rate-limited 30/5min.
Never stale (0027): a cached explanation is served only when its stored
engine formulasHash matches the report's engine version AND the finding's
measured fields are unchanged — an engine bump or data fix transparently
regenerates it, so prose can never describe pre-bump rules. AI-down → the
stale text (flagged stale: true) when one exists, else 503 that leaves the
finding untouched. Verified live on a real scan; shared public reports
intentionally have no Explain (AI stays behind auth).plans/
plan_limits; price edits go live without a rebuild. Pay-directly CTAs
(UPI/bank via info@productiveit.in) while checkout is inert.0023_razorpay_orders.sql +
0023_razorpay_flag.sql) — Orders-API integration behind the
razorpay_checkout flag (default DISABLED): server-created orders
(amount always from the plans table), HMAC-verified client handoff and
webhook (raw-body, idempotent via UNIQUE(event_id), CSRF-exempt as a
signature-authenticated callback), and plan activation only after Razorpay
confirms a captured, amount-matched payment (FOR UPDATE + paid latch —
Rule 74 holds). CTAs switch from contact-us to checkout only when flag AND
keys exist. Activation runbook: docs/BILLING.md.https://productiveit.in/docs/brandgrowth-os/
(how-to-use, scoring rules, brand, website handoff, security, changelog)
built by scripts/build-docs-site.mjs with a secret/ops scan gate and a
never-public list; synced by scripts/sync-docs-vps.sh with previous-
generation backup and public-HTTPS verification. Main-site footers link it.
The full 26-file internal set syncs to /opt/brandgrowth-os/docs.docs/WHITE_LABEL_EMAIL_DESIGN.md, docs/MAIL_MIGRATION_PLAN.md).scripts/digest-healthcheck.mjs,
cron 08:15 UTC after the 07:00 digest + 08:00 AI report) — verifies every
active workspace's digest actually landed and emails the verdict to
DIGEST_HEALTH_RECIPIENT (falls back to AI_HEALTH_RECIPIENT): ledger row
present, recipients > 0, queued-email count reconciles against the ledger
(rename-proof, matched by the subject's week stamp), AI insight delivered
where the week had content (parsed from the digest log's new run-start
marker; unverifiable → warn, never a guess), and no failed/stalled sends in
the outbox. Exit 2 on fails so the cron log pages loudly. Verdict lib is
pure (16 node:test cases) and ships with the run-marker emitted by
weekly-digest.mjs. Both paths proven live: ALL CLEAR (week 2026-09-07)
and a genuine FAIL alert (week 2026-09-14, pre-cron era) delivered to Gmail.scripts/deploy.sh post-health step) —
a successful deploy now rebuilds and republishes the public docs site on the
VPS whenever docs/*.md or the site builder changed (content-hash gated;
unchanged docs skip in one line). Fail-soft by contract: the publish runs
after the app health check, its failure (e.g. the builder's secret scan)
logs a WARNING and keeps the previous generation live, and the webroot's
.prev backup (same convention as the manual sync-docs-vps.sh) stays the
one-step rollback. Rehearsed in a sandbox: first publish, skip-on-unchanged,
backup-then-swap, and build-failure paths all verified.docs/AI_OVERVIEW.md →
/docs/brandgrowth-os/ai/) — the sanitized, customer-facing summary of the
AI layer: where AI appears and where it is deliberately absent, free-tier
policy, self-updating model selection, and privacy posture. The operator
AI.md stays internal; the builder's never-public list still guards it.app/src/components/app/header.tsx,
DOCS_URL in lib/brand.ts) — signed-in users reach the public docs from
every page. Absolute URL by contract: the docs live on the main site's
origin, not under the app's base path, so a path-relative href would 404
(the same basePath trap as the apiUrl rule). Shares the .bgo-app-action
tap-target class, so the responsive specs pin it automatically.NETWORK_ERROR_MESSAGE,
Message-ID domain stamping, brand chrome/logo under path hosting,
and deploy.sh hardened (systemd-aware restart, port-detection fallback,
settle-window health gate). deploy.sh now also provisions the worker venv
(workers/.venv) — created from workers/requirements.txt when missing,
hash-stamped so later deploys refresh it after a requirements change,
--skip-workers to opt out — closing the fresh-bootstrap gap where the
ingest/reap/sync crons failed hourly against a missing interpreter
(python3-venv/python3-pip added to the bootstrap's package list).roi / roi:table_present_but_sync_not_wired). 9/9 worker tests pass.0022_password_reset_otp (pre-rc.2 day) · 0023_razorpay_orders ·
0023_razorpay_flag · 0024_finding_explanations — all additive, forward-only.
bash -n + live runs in a scratch repo root).scripts/deploy.sh --skip-migrate. Migrations are additive — no down on production. The
worker-venv step is additive (--skip-workers leaves it untouched) and
removing the venv is always safe: the next deploy recreates it.UPDATE feature_flags SET state='DISABLED' WHERE key='razorpay_checkout'; reverts CTAs within the 60s flag-cache TTL.mv docs/brandgrowth-os docs/brandgrowth-os.broken && mv docs/brandgrowth-os.prev docs/brandgrowth-os in the webroot.This release candidate is the first coherent public-host state. It is ready for a founder-controlled VPS launch, but it is not a claim that DNS, SMTP, OAuth, Razorpay, or offsite storage has been provisioned; those remain launch-day inputs.
0018), shared across
restarts and app instances with fail-closed behavior.0019) with opt-out,
idempotent workspace/week ledgering, and existing outbox delivery.docs/SECURITY.md.0008_visibility · 0009_opportunity_candidates · 0010_brandmatch ·
0011_brandmatch_saves · 0012_sponsorship · 0013_roi · 0014_action_plans ·
0015_settings_billing · 0016_two_factor · 0017_recovery_codes ·
0018_rate_limits · 0019_weekly_intel_digest.
All migrations are additive and must be applied forward with the checksummed
migration runner. Do not run migration down on production.
docs/LAUNCH_DAY_RUNBOOK.md.scripts/deploy.sh, healthz, demo-off gate,
legal pages, cron commands, logical backup/restore, and loopback-only Postgres
binding verified locally./opt/brandgrowth-os, check out the recorded previous-good SHA
and run scripts/deploy.sh --skip-migrate; use docs/LAUNCH_DAY_RUNBOOK.md
for the health gate and emergency shutdown.down or drop production
tables to undo this release. Recover into a scratch cluster first, or restore
a verified logical/PITR backup with docs/DISASTER_RECOVERY.md, then apply a
separately approved cutover.0016_two_factor.sql) — encrypted enrollment from
/settings, password + authenticator-code login challenge, one-use five-minute
challenges, rate-limited verification, and password + code required to disable.
Social sign-in enters the same challenge flow: the OAuth callback hands the
browser a one-use challenge cookie and /login?next=2fa opens the code step —
no session exists until a valid code verifies.0017_recovery_codes.sql) — eight single-use backup
codes issued at activation and regenerable with password + live factor;
sha256-hashed, unambiguous alphabet, atomically claimed, accepted as the second
factor for sign-in and disable so a lost phone no longer locks a user out.0018_rate_limits.sql) — auth-limiter state
moved out of the Next process into rate_limit_buckets: one row per action+IP
holding the fixed-window counter and lock deadline together, mutated by atomic
SQL functions so limits survive restarts and are shared across app instances
(lib/ratelimit.ts, same call signature; an unreachable DB fails closed).
An armed lock freezes the bucket until expiry — refused hits consume nothing
and retryAfter never grows. Nightly cron prunes expired rows
(scripts/prune-ratelimits.mjs).WAL_ARCHIVE_DIR (wal_level=replica, archive_mode=on,
60-second archive_timeout); scripts/pitr-basebackup.sh creates the
physical base-backup half, and docs/DISASTER_RECOVERY.md documents replaying
that pair to a UTC recovery_target_time in a disposable scratch cluster.
RPO is explicitly conditional on the archive advancing and both pieces being
copied off-box.0019_weekly_intel_digest.sql) — the Monday
job (scripts/weekly-digest.mjs) summarizes the preceding UTC week per active
workspace: measured Visibility findings, new Opportunity candidates, current
plan, and plan-change requests. It queues one email per opted-in active member
through the existing outbox, defaults the preference on, and uses a unique
workspace/week ledger so retries cannot duplicate mail./product, /pricing, /how-it-works) plus a
(marketing) layout. /pricing renders the live plans/plan_limits rows, so
a seeded price change appears without a copy edit. Each capability block states
what the engine does and what it does not, and signed-in visitors get an
Open dashboard action instead of signup./legal/privacy, /legal/terms) — plain-language DPDP-2023
framing: what is stored, who can see it, retention, and the real export/delete
routes. The operator's entity (Productive iT, a partnership firm), grievance
contact (info@productiveit.in), and jurisdiction (courts at New Delhi) are
published, closing the launch checklist's item 12; the marketing spec asserts
the details render on both pages./onboarding) — profile-first: step 1 creates the
workspace's first business profile (forced primary), step 2 offers the free
visibility scan when a URL exists and says what to do when it does not. A
workspace with a primary profile is redirected to /dashboard, and the
dashboard offers Start guided setup → only until then./settings) — workspace rename, plan, notification preferences,
and your data. Backed by PATCH/DELETE /api/v1/orgs/current (delete requires the
workspace name typed exactly, is owner-only, moves or revokes the affected
sessions, and is audited before the cascade), GET/PATCH /api/v1/settings/notifications, GET /api/v1/account/export (own-data JSON
download), and DELETE /api/v1/account (password-confirmed; refused while the
caller is the last owner of a workspace that still has members).0015_settings_billing.sql → notification_prefs)
— per user, per channel; absent row means both on. notifyOrgPlanEvent now
respects them for each member and channel.scripts/lib/smtp.mjs, scripts/mail.mjs, scripts/mail.sh)
— env-gated (EMAIL_TRANSPORT=smtp), zero-dependency: EHLO, STARTTLS, AUTH
LOGIN, MAIL/RCPT/DATA with CRLF normalisation and dot-stuffing, per-row attempt
tracking and failed after SMTP_MAX_ATTEMPTS. Credentials are refused on a
channel with no STARTTLS. Console remains the default and marks rows sent at
insert, so pending always means "waiting for a real transport".GET/POST /api/v1/billing, the plan section in
/settings, and plan_change_requests. A request notifies platform admins
in-app and never changes orgs.plan_key: applying it is an operator action,
so no account can look subscribed without a payment. Razorpay payment links are
rendered only when they are https on a razorpay.com host.lib/plans.ts, requirePlanFeature()) — the
Opportunity Engine feed and the Action Plan (GET/POST/item PATCH) are Business
and answer 402 PLAN_REQUIRED with the tier that owns them. /opportunities
and /action-plan render an "Included on Business" lock rather than an empty
state, so the page and its API tell the same story.docker-compose.vps.yml) — ports: !reset [] removes
the development Postgres publish instead of adding another mapping.lib/seats.ts) — plan_limits.team_seats is shown on
/pricing, so it now holds: a seat is an active member plus an outstanding
invitation, checked when an invite is created and again when it is accepted (so a
downgrade in between is caught). The team panel shows {used} of {limit}.docs/DEPLOY.md §2.1) — 12 verifiable pre-launch items
(DNS, TLS, APP_ORIGIN, unpublished Postgres, SCAN_DEMO_ENABLED=0, cron,
proven restore, offsite copy, health, flags, restore owner, legal pages), each
with the command that proves it. Cron gains the mail drain.e2e-, so
every account created under older naming (warm-…@e2e-test.in,
scan-…@e2e-test.in) survived run after run and the dev DB drifted off baseline.
It now matches the test domain (%@e2e-test.in), the same predicate the
outbox cleanup already used; the drift that had accumulated was removed and the
DB is back to 1 user / 1 org / 0 profiles (the operator's own account).provider='smtp' rows are
actually waiting; GET /api/v1/admin/health now returns outbox_pending
(waiting for a transport) and outbox_stale (leftovers that will never be
delivered, so they want deleting) instead of one number that conflated the two.MAIL_MAX_AGE_DAYS defaulted to 3 days, longer than the links it guards.
Verification links live 24 h and resets 1 h, so a 2-day-old queue item was still
deliverable — a dead link in someone's inbox, which is the exact failure the
guard exists to prevent. The default is now 1 day, with the reasoning (and the
invite trade-off) recorded in scripts/mail.mjs.output: 'standalone' from app/next.config.ts. It described a
container path that does not exist (no Dockerfile; docker-compose.yml runs
Postgres only; scripts/deploy.sh starts the app with next start, which warns
when the option is set). The deploy shape in docs/DEPLOY.md §1 is now the whole
truth, .next/standalone is no longer emitted, and the reason is recorded where
the option used to be so it is not re-added without its container.playwright.config.ts
reused whatever answered on :3002 and ran next dev; combined with a second
next dev sharing app/.next, navigation waits blew past their timeouts and the
suite failed on correct code. It now builds and starts a production server
(reuseExistingServer: false), so a run either tests the real build or fails loudly.POST /api/v1/auth/login returned roleGlobal: "user" for platform admins,
so the admin console was unreachable on a fresh session. It now returns the real
role_global (P-026).\n, and the socket was destroyed before the QUIT write flushed.
Both are covered by isolation tests (P-022).GET /api/v1/account/export selected visibility_scans.target_url, a column
that does not exist (it is website_url), which made the whole export 500.team_seats was advertised but never enforced — every plan could invite
unlimited members. Invites and accepts now respect the plan's seat count.provider='console') entirely and
closes out anything older than MAIL_MAX_AGE_DAYS (3) as failed instead of
mailing a dead link; E2E teardown also deletes the outbox rows its users caused
(to_email has no foreign key, so they used to pile up as fake backlog).0015_settings_billing.sql — notification_prefs, plan_change_requests
(+ indexes and the updated_at trigger). Additive; no existing table altered.marketing (both signed-out and signed-in), onboarding (incl. the Business-only
campaign step), settings (rename/prefs/export/delete refusals + the billing flag
both ways), Free-vs-Business 402 on opportunities/action-plan/notifications,
and the team seat limit (Free refuses a second invite, Pro allows it).global-teardown also removes the outbox rows the test users caused
(to_email has no foreign key), so the dev DB returns to baseline and the admin
outbox counter cannot show test litter as backlog.mail.mjs run that flipped an
outbox row to sent.DELETE /api/v1/orgs/current is immediate and cascading; there is no retention
hold, so a mistaken owner deletion is recoverable only from a dump.git checkout <previous-sha> && scripts/deploy.sh --skip-migrate) leaves 0015 in place; both tables are inert without the routes.packages/engine/src/visibility.ts) — the 30
checks of SCORING_RULES §2 with their documented weights and pass rules, section
scoring where a not-measured check never penalises, the nine Critical
Opportunity rules with a fix category and service owner, and the confidence
inputs (dataQuality, confidenceMultiplier, coverage). Pure functions: no
I/O, no database, no HTML parsing.packages/engine/src/html-signals.ts) — reads the
facts the checks judge (title, description, Open Graph, canonical, viewport,
headings, image alt ratio, word count, JSON-LD, mixed content, NAP, map link,
hours, robots.txt, sitemap, trust pages, declared profile links) out of fetched
markup. The scanner does the fetching; this layer is deterministic over strings,
so a fixture and a live scan share one code path.packages/engine/test/fixtures/sites.ts) as the
milestone's documented expected outputs: each records its expected 30-check
status string and finding keys, and the test recomputes every score from those
statuses with an independent implementation of the section rule.maxMeasuredTotal / normalizedTotal on a scan result — an unauditable
section contributes no points, so the report states the total against what was
actually reachable (unknown-social: 80 of 85, 94.1% of what we could check).app/src/lib/scan/fetch.ts, run.ts) — SSRF-guarded homepage
fetch, robots.txt first (blanket Disallow: / suppresses sitemap/404 probes),
linked contact/privacy/terms, TLS days, RDAP age, social reachability. Request
log is stored on the report so the scan states its own effort.0008_visibility.sql — visibility_scans, visibility_reports,
visibility_findings, leads (consent_at NOT NULL).GET/POST /api/v1/visibility/scans, GET …/scans/[id],
POST …/scans/[id]/share, public GET …/shared/[token], POST /api/v1/leads
with required consent: true. Free-tier quota via plan_limits.scans_per_month./visibility run form + history, /visibility/[id] score /
sub-scores / 30 checks with USER SUBMITTED labels / Critical Opportunities /
lead CTA, shareable /r/[token] (noindex, no tenant ids).packages/engine/src/brandmatch.ts) — SCORING_RULES §3
(audience/geo/category/budget, not-measured redistribution, q from data quality).
Fixtures pin the two worked examples. Migration 0010_brandmatch.sql
(partner_fees, partner_match_runs, partner_match_scores,
partners.segment_codes). API GET /partners, GET/POST /partners/match,
UI /partners. Free plan quota is 0 (Pro feature). No 200-row invented seed —
the catalog is the two verified ingest partners. brandmatch flag defaults to
ENABLED. Sync copies the latest run into opportunity_candidates.GET/PATCH /api/v1/notifications, POST …/read-all,
UI /notifications. Action Plan generate/complete writes in-app rows and
email_outbox (console transport).GET/POST /api/v1/action-plan, PATCH …/items/:itemId,
GET …/items/:itemId/explain (this release), UI /action-plan. Growth Score
(packages/engine/src/growth.ts) skips unused engines; G is null until
something is measured. Item explanations reuse the finding-explainer facts-onlyGET /api/v1/opportunities + /opportunities.
Org-scoped opportunity_candidates, priority DESC. Empty list if unsynced.0014_action_plans.sql) — action_plans,
action_plan_items, notifications. Growth scores nullable (no fake 0)./roi) — labeled scenario form, origin per field, comparator.
Nav + dashboard CTA. Blank cost stays not-measured.POST /api/v1/roi/analyze, GET /api/v1/roi/:id,
POST /api/v1/roi/compare, GET /api/v1/roi/sets/:id. Flag roi_engine
ENABLED. Quota roi_analyses_per_month. Missing cost is not-measured.0013_roi.sql) — roi_comparison_sets, roi_analyses
(comparison_set_id), engine_versions row roi/1.0.0 hash 37294d3c.
Rank weights in weights_config. No market-rate benchmarks rows.packages/engine/src/roi.ts) — SCORING_RULES §5.
Inputs labeled measured|benchmark|manual. Missing cost never invents a fee;
zero engagements → CPE not-measured. Option rank renormalises missing sections.docs/SOURCES.md.workers/sync.py) — latest
opportunity_scores fan in; affordability/ROI not-measured; missing timing
does not silent-zero. ROI still skipped./sponsorship) — real ingest events only; empty state if
none; score against a profile; pipeline select; Free/Pro honest Business-feature
copy. Flag sponsorship ENABLED. Nav + dashboard CTA.GET /api/v1/sponsorship/opportunities,
POST …/:id/score, POST …/:id/state. Flag sponsorship stays DISABLED.
Free/Pro → 402 (Business feature). No invented events; scale uses sourced
tier prices only.0012_sponsorship.sql) — opportunity_tiers,
opportunity_org_state, opportunity_scores, engine_versions row
sponsorship/1.0.0. No seed fees or footfall.packages/engine/src/sponsorship.ts) — SCORING_RULES §4
(category 0.35 / geo 0.25 / scale 0.15 / timing 0.15 / source 0.10). Unknown
footfall and missing start dates are not-measured (never invent a day). City
match 1 / national 0.8 / same state 0.5. 10 fixture tests. No live event seed.0011_brandmatch_saves.sql) — org-scoped
snapshots, catalog filters (kind/city/q), run filter/rank, confidence
labels (method + range when data quality is low). /partners/saved compare
table. Still no invented partner seed./api/v1/visibility/demo/[key] when
SCAN_DEMO_ENABLED=1 (never a product flag).docs/API.md §8, docs/SECURITY.md (SSRF, consent, scanner ToS),
docs/SOURCES.md (USER_SUBMITTED scan policy), docs/DISASTER_RECOVERY.md,
docs/DEPLOY.md, deploy/nginx.conf.example. visibility_scan flag defaults
to ENABLED. scripts/ingest.sh cron wrapper for the ToS-safe adapter.docs/SCORING_RULES.md §2 worked example corrected from 86 to 79.4. Its
stated arithmetic contradicted its own §1.3 twice: it left failing check 20 out
of Local's denominator and treated failing check 25 as unmeasured. A check that
runs and fails counts in max_measured; only checks that cannot run leave it.
Two rules the doc left open are now pinned down in code and tests: check 25 is
not_measured (not fail) when the owner declared no accounts at all, and a
self-reported answer carries confidence 0.35 rather than 1.0.packages/engine npm test now builds first — the sources use .js specifiers
for their compiled ESM output, so the suite runs against the built artifact.scripts/deploy.sh binds next start to 127.0.0.1 (Nginx terminates TLS).workers/reaper.py, --reap / scripts/ingest.sh reap):
past opportunities → historical_campaigns + status=past; stale
partners/opportunities drop verified.0009_opportunity_candidates.sql, workers/sync.py,
scripts/ingest.sh sync): latest Visibility findings fan into
opportunity_candidates. Latest BrandMatch run is copied once 0010 lands;
Sponsorship / ROI stay skipped. Verified partners stay the searchable ingest catalog.SCAN_DEMO_ENABLED=1 relaxes the private-address SSRF guard for demo paths
only. scripts/deploy.sh warns if it is set; leave it off on a public host.expectServedOverHttp().Status: feature-complete and verified locally on a production build (next start,
port 3002). Not yet deployed to a public host, and not tagged in git — there is no
remote configured, so this changelog is the release record.
bg_session, sha256 at
rest), ownership-scoped orgs, audit_logs, feature flags, /healthz.YYYY-MM-DD date windows validated as a pair, up to 50 target geos.0007_org_invites.sql, lib/invites.ts). Ownership is never invitable,
an admin can never invite a peer admin, and an org always keeps ≥1 owner.
Removing a member revokes their live sessions for that org, so access ends
immediately rather than at the next login.0006_oauth.sql, docs/OAUTH.md).workers/ingest_and_verify.py),
ToS-safe source adapters (docs/SOURCES.md), and the first real adapter reading the
live ISL 2026–27 article via the MediaWiki API. Non-HTTP; no cron trigger yet.scripts/deploy.sh (checksummed migrations, detached start, health gate
that proves the started PID survived), scripts/backup.sh (pg_dump + sha256 sidecarscripts/restore.sh (checksum-verified, typed
confirmation).auth, profiles, campaigns,
admin, team (5 specs), plus a DB-hygiene teardown that removes test rows.a75e1b4)date columns shifted a day in non-UTC timezones. Postgres date came back as a
local-midnight Date and toISOString() moved it backwards (2026-10-20 →
2026-10-19 in IST). Mappers now read local components. (b09a7c1)?? treated an explicit null as "not sent", so
dates, budgets, website and socials were impossible to unset on both profile types.
Both routes now merge on key presence and validate the merged record. (6a6a0b3)script-src 'self'
rejected Next's inline RSC bootstrap; the policy moved to middleware with a
per-request nonce (x-nonce forwarded), dev relaxing to inline+eval for HMR. (2059b66)memberToJson was moved to
route-local scope after next build rejected it as an invalid Route export field.0001_identity_auth · 0002_taxonomy_plans · 0003_profiles · 0004_platform_infra ·
0005_ingestion · 0006_oauth · 0007_org_invites (new in this release).
Applied to the dev database (schema_migrations = 7). All are additive: new tables
(org_invites, oauth_identities, oauth_states) and indexes — no column dropped or
rewritten, so existing rows are untouched.
npm run typecheck -w app clean; packages 8/8 built and tested; worker offline tests
7/7 (workers/tests/).403 on campaign create / invite / profile patch,
duplicate invite 409 INVITE_PENDING, existing member 409 ALREADY_MEMBER,
role=owner 400 VALIDATION, reused/expired/revoked invite 400 INVALID_TOKEN,
wrong-email accept 403 EMAIL_MISMATCH, self-removal 400 INVALID_ACTION./auth/login is rate limited (gap #1).console — verification, reset and invite links land in the
server log, which is why POST /orgs/invites returns acceptUrl to its caller. This
must be removed once a real transport exists (gaps #1 and #3).SameSite=Lax (gaps #4/#5).git revert the offending commit range; nothing in this release depends on
schema that predates it except org_invites.0007_org_invites is additive — DROP TABLE org_invites; (plus
DELETE FROM schema_migrations WHERE version = 7;) fully undoes it without touching
user, org, profile or campaign rows. Earlier migrations should not be rolled back in
isolation; restore from the latest scripts/backup.sh archive instead.bash scripts/deploy.sh --stop halts the app; scripts/restore.sh restores a
verified archive and can sign everyone out (--revoke-sessions).:5434).packages/*: shared types, DB client and engine utils (8/8 tests).0005_ingestion landed.