A multi-agent audit of every doc against the code surfaced ~50 stale/missing
items (the roadmap/status docs and the backlog had fallen behind the code).
This catches them up:
- CLAUDE.md: phase status was ~3 phases stale ("Phase 1 is next" while Phase 1 +
chunks of 2 & 4 shipped). Rewrote the status list; added a model-provider
tech-stack entry; updated repo-layout (integrations objectstore/models,
deploy backup.sh/dev compose).
- ARCHITECTURE.md: §6 privacy engine described 3 visibility levels — corrected to
the shipped 4 (adds site_members); documented per-tree AI policy on Tree,
LLMProvider/EmbeddingProvider split + registry, ChangeProposal origin/status/
operations, verified-email session gate, instance-owner role, schema-drift
guard, and the env_file config model.
- PRD.md: 4-level visibility in US-040/§5.5, instance-owner role (§5.1/§5.11),
per-tree AI policy (§5.8), §8 sequencing annotated with shipped status, header
date/status bumped.
- README.md: 4-level privacy; softened "Full GEDCOM 7" to the 5.5.1/7 common
subset; noted backups + instance-owner admin; moved property/land to an
explicit "where it's headed" (no property models exist yet).
- BACKLOG.md: flipped ~15 shipped-but-open rows to Have (ChangeProposal, provider
abstraction, GEDCOM citation export, membership management, operator backup,
email-verification gate, per-tree AI policy, instance owner, the whole
visibility/public-viewing/child-resource-redaction cluster #41-#51/#46), and
reconciled the executive summary, "current defects" list, quick wins, and
differentiators. Left genuinely-open items (citation/source redaction, sitemap,
per-tree noindex, scoped-token API) accurately open.
- .env.example: dropped "SMTP wired in a later phase"; documented the worker
purge knobs, S3_PRESIGN_TTL, COOKIE_NAME; removed a stray duplicate line.
- design/: tree-visibility.md and change-proposal.md marked Shipped; corrected
the redaction approach (reuses member schemas, not a separate PublicPersonRead)
and the apply() rollback claim (v1 is not cross-op transactional), and marked
rate-limiting/sitemap/noindex as deferred.
No code changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Replace the per-setting environment allow-list with `env_file: .env` on the
three app-image services, so every setting in app/core/config.py is configurable
from .env with no compose edit. This kills the recurring trap where a documented
env var (OWNER_EMAIL, the AI keys, SMTP, APP_BASE_URL) silently didn't reach the
app because it wasn't on the hand-maintained list.
`env_file` is `required: false` so local/CI without a .env still works (falls
back to ${VAR:-default} interpolation + code defaults). The small `environment:`
block that remains is only for values that must NOT come from .env:
- RUN_MIGRATIONS=1 (backend) — a deploy flag, not an app setting.
- DATABASE_URL — pinned to the compose-internal host, because the code default
points at localhost (wrong inside the network). environment wins over
env_file, so this is a safety net if .env ever omits it.
Trade-off (accepted, see comment): env_file also injects infra secrets
(POSTGRES_*, MINIO_*, CLOUDFLARE_TUNNEL_TOKEN) into the app process env; the app
ignores unknown vars (pydantic extra="ignore").
Verified on prod: DATABASE_URL resolves to postgres:5432, RUN_MIGRATIONS=1 and
OWNER_EMAIL intact, COOKIE_SECURE=true (no posture change), health 200, trees
200. The earlier explicit AI/SMTP/OWNER passthrough is now subsumed by this.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Follow-up to the OWNER_EMAIL passthrough. The backend service env block is an
explicit allow-list, so the documented model-provider keys (ANTHROPIC_*,
OPENAI_*, XAI_*, OLLAMA_*, DEFAULT_*_PROVIDER, LLM_MAX_TOKENS,
EMBEDDING_DIMENSIONS) and mailer settings (MAILER, SMTP_*, APP_BASE_URL,
REQUIRE_EMAIL_VERIFICATION) never reached the container — setting them in .env
was a no-op. The AI assistant/policy and the SMTP mailer run in the backend, so
forward them here.
Side fix: APP_BASE_URL was likewise dropped, so outbound email links used the
code default http://localhost instead of the configured domain. Now forwarded
(verified live: backend reports APP_BASE_URL=https://provenance.paul.farm).
Worker is left as-is (it consumes neither today); it'll need the model vars when
embedding/matching jobs land. Alternative to this growing allow-list is
`env_file: .env` on the service — deferred to avoid forwarding unrelated secrets.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
The instance-owner feature reads OWNER_EMAIL, but the backend service's
environment block is an explicit allow-list that didn't include it — so setting
it in .env never reached the app (is_instance_owner always saw "" → no owner).
Add the passthrough.
NOTE: the same allow-list omits the AI provider keys (ANTHROPIC_API_KEY,
OPENAI_*, XAI_*, OLLAMA_*) and SMTP settings, so those documented env vars also
don't currently reach the backend on this deployment. Worth a follow-up
(forward them explicitly, or switch the service to env_file) so .env actually
drives all configuration per the twelve-factor rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Provenance had no system-level owner: ownership was only per-tree
(TreeMembership), so a self-hosted instance had no operator account and no
instance-admin surface. This adds one, declared by environment per the project's
twelve-factor rule.
- OWNER_EMAIL (comma-separated): the account(s) named here are instance owners.
Derived at request time — no DB column, no migration, can't drift from the env,
survives DB resets. is_instance_owner()/InstanceOwner dependency in api/deps.py.
- Ownership requires a VERIFIED email (independent of REQUIRE_EMAIL_VERIFICATION).
Registration is open, so without this an attacker could seize the role by
registering the owner address first; verification ties it to inbox control.
- GET /api/v1/admin/instance (owner-only): operational status — version, env,
user/tree counts, configured AI providers. Deliberately exposes no tree data
or PII: instance ownership is an operator role, NOT a privacy-engine bypass.
- /users/me reports is_instance_owner; frontend gains an owner-only /admin page
and a conditional sidebar link (server-enforced, not just client-hidden).
Found-and-fixed by an adversarial security review before merge: the
verified-email land-grab (above) and a frontend null-deref where the admin page
crashed on 401/5xx instead of failing closed.
Docs: .env.example + ARCHITECTURE (notes the not-a-privacy-bypass boundary and
the verified-email requirement). Tests: owner matching, the land-grab guard,
/users/me, and owner-only /admin. Suite 96 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Defense-in-depth for the deploy pipeline. Today a backend image shipped ahead
of an un-applied migration; the Tree model selected columns the DB didn't have
yet, so every trees query 500'd with an opaque UndefinedColumnError and the UI
showed no trees. The root cause (deploys not running migrations) is fixed
separately; this makes the *symptom* impossible to miss.
- app/core/schema_version.py: compare the DB's stamped alembic head to the
head(s) baked into the image's migration scripts. A DB with no alembic_version
table (e.g. a create_all test DB) is treated as current, so this stays quiet
outside real deployments. Uses to_regclass so a missing table never poisons
the caller's transaction.
- /health/ready: returns 503 with an explicit "drift: db=… expected=…" message
when the schema is behind, instead of reporting ready and serving 500s.
- Startup lifespan: logs CRITICAL on drift (advisory — never blocks startup).
Liveness (/health) is untouched, so a drifted container isn't killed into a
crash-loop — it's loudly degraded and self-heals once migrations apply.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
The operator decides which model providers exist (env / registry — Anthropic,
OpenAI, x.AI, Ollama, several at once). The *tree owner* decides who uses which:
- Members' assistant -> one configured provider (or none)
- Recommender (association/connection finder) -> one configured provider (or none)
- Owner -> may use any configured provider
Backend: two nullable columns on `trees` (ai_member_provider,
ai_recommender_provider) + migration; `configured_llm_providers()` exposes the
registry as {name, model} with no secrets; owner-gated GET/PATCH
/trees/{id}/ai validate names against the configured set. Frontend: owner-only
"AI models" page with a dropdown per role, graceful 403 for non-owners, and a
sidebar link.
Per-model-within-a-provider selection is a follow-up; today each provider maps
to its single configured model.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Extends the #215 abstraction:
- OpenAICompatibleLLMProvider / OpenAICompatibleEmbeddingProvider — one impl (via
the official openai SDK) covers OpenAI, xAI (api.x.ai/v1), Ollama
(…:11434/v1), OpenRouter, etc.; they differ only by base_url, key, and model.
- Registry factory: build_llm_providers() / build_embedding_providers() return
every provider whose credentials are configured, so you can run several
concurrently. get_llm_provider(name)/get_embedding_provider(name) select by
name, falling back to default_*_provider, then Null.
- Per-provider env config (ANTHROPIC_*, OPENAI_*, XAI_*, OLLAMA_*) +
DEFAULT_LLM_PROVIDER / DEFAULT_EMBEDDING_PROVIDER; documented in .env.example.
Defaults keep AI off (empty registry).
Embeddings now have real backends (OpenAI/Ollama), still separate from the LLM
since Anthropic offers no embeddings endpoint. Tests cover multi-provider
selection, default resolution, disabled-without-credentials, and null fail-loud.
Full suite 87 passed.
Relates to #215.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Implements non-negotiable #1: the AI assistant never writes autonomously. Every
assistant/contributor "write" emits a ChangeProposal — a structured diff a human
approves, edits, or rejects. Design: docs/design/change-proposal.md.
Structural guarantee: a proposal's operations reach the DB ONLY via
change_proposal_service.apply(), which requires the actor be an editor and
dispatches each op through the normal editing services (person/name/event/
relationship/source/citation create/update/delete) — so every change passes the
privacy engine and is audited as the approving human. propose() only inserts a
pending row; it performs no domain mutation. Model providers stay read-only, so
no model response can mutate tree data.
- ChangeProposal model + migration (status pending|applied|rejected, origin
assistant|contributor, JSONB operations, reviewer + apply_error).
- Service: propose / list / get / apply (with optional edited ops) / reject /
delete; a dispatcher mapping ops → editing services. v1 applies ops in order,
not cross-op transactional (single-op is atomic; documented).
- API /trees/{id}/proposals + a frontend review page (approve/reject; editor-
gated) and sidebar entry.
Tests: proposal doesn't apply until approved; reject doesn't apply; non-editor
member can see but not apply; multi-op; approve-with-edits; apply-error keeps it
pending. Full suite 87 passed; single alembic head.
Closes#214
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Adds the vendor-agnostic seam the AI assistant + match-ranking plug into:
- LLMProvider / EmbeddingProvider ABCs (base.py). LLM and embeddings are
SEPARATE abstractions — Anthropic has no embeddings endpoint, so each is
configured independently and either can be off.
- NullLLMProvider / NullEmbeddingProvider — the default; fail loud with a clear
"not configured" error so AI-off deployments don't silently no-op.
- AnthropicLLMProvider — first concrete LLM impl, via the official anthropic SDK
(default model claude-opus-4-8). A local provider (e.g. Ollama) would be
another subclass of the same interface.
- Factory in deps.py (get_llm_provider / get_embedding_provider) selects by
env (MODEL_PROVIDER / EMBEDDING_PROVIDER); documented in .env.example.
Providers are read-only text/vector producers — they never touch the DB, so the
"AI never writes autonomously" invariant (CLAUDE.md #1) holds; writes will go
through ChangeProposal (#214).
Tests: provider selection (null default, anthropic when keyed, fallback without
key) + null providers raise. 81 passed.
Closes#215
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Move backup from a documented procedure to `deploy/backup.sh`: dumps Postgres
(pg_dump --clean --if-exists, gzipped) and archives the MinIO /data directory
into a single timestamped bundle under backups/. Reads config from the compose
.env with the same defaults the stack uses; optional BACKUP_RETAIN_DAYS prunes
old bundles (cron-friendly). BACKUP.md documents usage + the restore procedure
(kept manual/documented rather than an untested destructive script).
Closes#196
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
TreeMembership was enforced on every read/write but had no API or UI to manage
members — trees were effectively single-user, breaking full-CRUD (NN#8).
Backend (/trees/{id}/members): list (members only — the list exposes emails, so
non-members never see it, even on public trees); add an existing user by email
(owner only, 404 if no such account, 409 if already a member); PATCH role;
DELETE. A tree must always keep ≥1 owner (demote/remove of the sole owner → 409).
All changes audited.
Frontend: a Members page (owner gets add-by-email + per-member role select +
remove; others see a read-only list) and a sidebar entry.
Test covers the full lifecycle + every guard. Suite 77 passed.
Closes#145
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Export emitted SOUR records but never the per-fact SOUR links, so a
Provenance→Provenance round-trip destroyed the sources graph (citations were
dropped). Emit citation links on the facts they sit on:
- person-level → 1 SOUR @Sx@ (2 PAGE)
- name-level → 2 SOUR under 1 NAME
- event-level → 2 SOUR under the event (incl. partnership events in FAM)
- relationship → 1 SOUR under FAM
Citations whose source didn't export are skipped.
Test: a person + event citation round-trips through export→import into a fresh
tree with their pages intact. GEDCOM suite 6 passed.
Closes#169
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Backlog §2.10: registration issued a live session and email_verified_at was
written but never read, so an unverified user had full access and there was no
switch to require verification.
Add REQUIRE_EMAIL_VERIFICATION (default false). When true:
- resolve_session_user returns None for a user whose email_verified_at is null —
the single read-side gate covering every authenticated request, incl. the
session minted at registration.
- login raises 403 ("email not verified") instead of issuing a useless token.
Default false on purpose: self-hosts without SMTP, and accounts created before
this gate existed (email_verified_at null), must not be locked out. Operators
enable it once mail works and accounts are verified. Documented in .env.example.
Tests: default-off keeps unverified accounts working; on → register's session
won't resolve (401), login is 403, and after verify-email both work. 75 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Unset sex renders blue (male-colored), which is misleading next to a confirmed
male partner. Add a Cleanup action that proposes the opposite sex for an unset
partner of someone whose sex is set (couples are opposite-sex in practice — a
confirmed-male husband ⇒ a female wife). People whose known partners disagree
are skipped as ambiguous.
It's a preview the user reviews and approves in the Cleanup tool (reusing the
existing gender apply path + audit) — not an autonomous write. Backend:
guess_gender_by_spouse + GET /cleanup/gender/from-spouse. Frontend: an "Infer
from spouse" button feeding the existing proposal list. Test covers
propose-opposite, skip-no-partner, skip-already-set, apply, and re-preview.
Full suite 73 passed; frontend build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
The public tree chart was fixed at 3 ancestors / 2 descendants. Add the same
Generations controls the member view has (slider + number stepper + "All" per
direction), applied live around the focused person.
Extracts the member page's inline DepthControl into a shared
components/depth-control.tsx and uses it in both, so they stay in sync. The
public chart gains anc/prog depth state + an apply effect (setAncestryDepth/
setProgenyDepth + updateTree) mirroring the member behavior.
tsc clean; next build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
The public layout forced max-w-5xl on every /p page, so the tree chart was
cramped. Mirror the member shell: the public layout now drops the max-width for
the tree page (/p/<id>) only, giving the chart the full canvas (74vh to match
the member view), while the page keeps its heading and people list in a
centered max-w-5xl column. Person detail (/p/<id>/persons/<pid>) and /explore
stay narrow.
tsc clean; next build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Two gaps from review of the public surface:
- The public tree page showed only a list of names. Add the family-chart
hourglass (PublicTreeChart) above the directory — the same renderer the
member tree view uses, including the cycle-sanitisation that guards against a
bad graph, fed by redacted public data. Click a card to recenter; "Open"
links to the person's public page. Centers on the tree's home person.
- The homepage had no path to /explore. Add an "Explore" nav link and an
"Explore public trees" hero button.
tsc clean; next build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
A no-login directory of shared trees, backed by GET /api/v1/public/trees:
- /explore: searchable grid of public trees; debounced name search. Because the
backend adds `site_members` trees when a valid session is present, signed-in
users see more with no client-side branching.
- PublicHeader extracted and shared by /p and /explore (logo, Explore, Sign in).
- "Explore" entry added to the authed sidebar.
tsc clean; next build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Adds the public viewing surface in the UI — shareable, no-login pages backed by
the redaction-safe /api/v1/public API:
- /p/[treeId]: tree name + searchable people directory (living people show as
"Living person"; counts; links to person pages).
- /p/[treeId]/persons/[personId]: person detail — events, alternate names, and
parents/partners/children as links to other public person pages.
- app/p/layout.tsx: slim public header (logo + Sign in), no app sidebar.
- robots.ts: allow /p/, disallow the authenticated app sections.
- Trees list: a "Public page ↗" link on every non-private tree so the owner can
grab the shareable URL.
Client-rendered (same-origin fetch via Caddy). Follow-up (needs a frontend
SSR→backend base URL + a compose/env deploy step, so not auto-applied by
Watchtower): true server-rendering for SEO, a dynamic sitemap of public trees,
and per-page noindex for unlisted/site_members.
tsc clean; next build passes (both routes dynamic).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
A logged-in NON-member of a public/unlisted tree could read living people's
dates, real alternate names, and media (incl. downloading photos) through the
family-view endpoints — only the person LIST was redacted; list_events,
list_relationships, list_names, list_media gated on can_view_tree alone.
For non-members, these now delegate to the same visibility-filtered reads the
public surface uses (person_visibility-driven): living-person events/names
dropped, relationships touching a hidden person dropped, media limited to
full-visibility persons, and media download (get_media → media_content) 404s
for a redacted/unlinked person's media. Members are unchanged.
Adds list_public_relationships_for_person / list_public_media / can_view_media
to public_view_service. Test: an authed non-member sees no living-person PII
across events/names/relationships/media and can't download a living person's
file, while the owner still sees everything. Full suite: 72 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Output of a multi-agent gap analysis comparing Provenance against commercial
(Ancestry/MyHeritage/FamilySearch) and open-source (GRAMPS/Gramps Web/webtrees)
genealogy software: 15 research lenses, 580 raw features deduped into a 17-
category taxonomy, 302 features assessed against the codebase (have/partial/
planned/missing) with statuses verified against the actual code.
Includes an executive summary, per-category backlog with status/importance/
effort/phase, a quick-wins shortlist, and strategic differentiators. Statuses
reflect the repo at analysis time (before tree-visibility phases 1-3); a couple
of flagged items (e.g. the site_members tier) are already closed.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Adds the anonymous read surface (/api/v1/public) — the privacy-critical core.
- CurrentUserOrNone dependency: optional auth that never 401s (anonymous OK).
- public_view_service: every projection passes through privacy.person_visibility.
persons redacted (living → "Living person", hidden dropped); relationships
only when both endpoints non-hidden; events only for FULL-visibility persons
(partnership events only when both partners full); names only for FULL
persons. Not-viewable trees raise 404 (not 403) so the surface can't probe
for private trees. Media deferred (higher-sensitivity; own pass later).
- public router: read-only directory + tree + persons/relationships/events +
person detail/names/events. Directory lists `public` to all and adds
`site_members` for authenticated callers; never lists unlisted/private.
- PublicTreeRead omits owner_id.
Tests (ran locally — CI does not run pytest): an anonymous end-to-end leak test
asserting a living person's real name, alias, and birth year appear in NO public
response while the deceased person's data does; plus private=404, unlisted
viewable-by-link-but-unlisted, site_members requires login, and directory
visibility. Full suite: 70 passed. Regenerated openapi.json + TS client.
Note: the AUTHED list endpoints still leak per-person for non-members
(pre-existing) — fixed next, separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
can_view_tree() now distinguishes anonymous vs authenticated non-members so the
four-level model is enforceable:
- public / unlisted → anyone, including anonymous (unlisted gated only by the
link, so the API must never *list* it)
- site_members → any authenticated account (denies anonymous)
- private → members only
Members (any role) always view; soft-deleted trees stay hidden from everyone.
person_visibility (living-person redaction) is unchanged.
Tests: a full can_view_tree matrix across {anonymous, logged-in non-member,
member} × {public, unlisted, site_members, private}, plus deleted-tree-hidden
and the site_members anon-vs-logged-in case. Adds `engine`/`db_session` fixtures
(refactored out of `client`) so the engine can be unit-tested directly,
including the anonymous path that has no HTTP endpoint yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
First step of the public-viewing feature (design: docs/design/tree-visibility.md).
No non-member behavior change yet — this only widens the vocabulary and UI.
- TreeVisibility gains `site_members` (any authenticated user of the instance),
giving the four-level model: public / site_members / unlisted / private.
- Alembic migration adds the enum value via an autocommit block (ALTER TYPE
ADD VALUE can't run in a transaction on older Postgres); downgrade is a no-op
since PG can't drop an enum value.
- Regenerated openapi.json + frontend TS client.
- Trees-list dropdown now offers Private / Public – Members / Unlisted / Public
with an explanatory tooltip.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Tree visibility was set to private with no UI to change it — the trees list
only displayed the value as text. Add a private/unlisted/public dropdown on
each tree card that PATCHes visibility immediately (optimistic), pulled out of
the card's navigation Link so it doesn't trigger a page change. Honors the
"everything configurable / full CRUD in the UI" invariants. Living people stay
protected by the privacy engine regardless of tree visibility.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Depth was hardcoded (3 ancestors, 2 descendants). Add a controls row to set
each direction independently — a slider plus a number stepper, with an "All"
toggle per direction — applied around whoever is currently focused.
- ancestor/descendant depth held in state; effective value is a large cap
when "All" is on (the chart only renders people that exist, so the cap is
free).
- changes apply to the live chart via setAncestryDepth/setProgenyDepth +
updateTree without a full rebuild.
- fan mode (ancestors only) takes the ancestor depth via its `generations`
prop, capped at 8 to avoid the radial layout's 2^n blow-up; its descendants
control is disabled with a note.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Setting a person's sex meant clicking Edit, opening a dropdown, and saving.
Replace the read-only ♂/♀ symbol next to the name with an always-visible
two-button segmented control that PATCHes immediately on click (gender-only;
backend PATCH is exclude_unset so the name/other fields are untouched).
Clicking the active sex clears it. The full edit form still offers gender for
completeness.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Adding a marriage/partnership event used a plain <select> for the spouse,
which is unusable on a large tree — you can't search, only scroll. Swap it
for the existing PersonCombobox (already used by the relationship form), which
filters by name as you type. No onCreate, so it still resolves to an existing
person id, which is what the partnership-event handler requires.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
family-chart 0.9.0 stacks all of a person's spouses on one gender-determined
side, so someone with two spouses (e.g. a woman with two husbands) renders
with both spouses piled above/below her and ambiguous child lines.
Patch the library (via patch-package) so the person stays centered and their
spouses split to alternating sides — spouse 1 above, spouse 2 below, further
spouses farther out — and order each couple's children to match, so children
descend from between the correct pair without crossed lines:
- setupSpouses: keep the person centered; place spouses at alternating
offsets and recenter the cluster on the person's slot.
- sortChildrenWithSpouses: order children by spouse order (gender-independent)
to match the new spouse positions.
Adds patch-package + a postinstall hook, and COPY patches into the Dockerfile
deps stage so the patch applies during `npm ci` in CI. Verified the patch
re-applies on a clean install and the production build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Once you recenter the tree on someone, there was no quick way back to the
tree's home/default person. Add a header link (shown only when a home person
is set and you're not already on them) that recenters the chart on
home_person_id via the existing goTo() — works in landscape, portrait, and
fan modes. Labels with the home person's name for clarity.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
The Tree view, People (Family) view, and person detail page each tracked
the "current person" independently, so moving between them reset you to the
home person. The detail page's "← Back to tree" link also pointed at the
People view (not the Tree) and carried no person, so it always landed on the
default person.
Make the focused person a URL-encoded concept that travels across views:
- Tree and People views read ?focus=<id> on load and mirror the focused
person back into the URL via router.replace (no history spam), so leaving
and returning keeps you centered where you were. Bookmarks/shared links
also resolve to the right person.
- "Open person" links carry ?from=tree | ?from=people.
- The detail page's back link is now origin-aware: "← Back to Tree" →
/tree?focus=<id> or "← Back to People" → /?focus=<id>, returning you in
place instead of to the home person.
- Add a "View in tree →" link on the detail page — the previously missing
direct jump from a person to the tree re-rooted on them.
- person→person relationship links (and create-relative redirect) pass
`from` through so click-chains keep their anchor.
Also gitignore *.tsbuildinfo (Next build artifact).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Root cause of the blank Jung tree: a child double-linked to the same parent
(and, generally, any cycle) made family-chart recurse forever.
Backend (the real fix):
- create_relationship now rejects an equivalent existing edge → 409.
parent_child is directional (parent→child); partnership/sibling match the
pair in either order. So you can't link the same two people the same way
twice. (GEDCOM import already deduped; manual creates didn't.)
Frontend (defense in depth so data can never blank the view):
- Tree view sanitizes the graph before rendering: dedupes parents/spouses,
drops self-links, and greedily breaks ancestor cycles (a person can't be
their own ancestor); children are derived from the kept edges. The render is
wrapped in try/catch and shows a note instead of a blank canvas, telling you
which conflicting links were skipped.
- Person page surfaces the 409 ("They're already linked that way.").
59 backend tests pass (incl. dup-rejection + reverse-parent-child allowed).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Adds a "People with no sex set" section to the Cleanup page — lists everyone
whose gender is still null with inline ♂ Male / ♀ Female buttons (and a link to
their page). Refreshes after the source-match and first-name guess passes, so
it's the manual mop-up for whatever those leave behind.
Frontend only (reuses person list + PATCH) — no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
A "Guess from first name" option in the Cleanup gender section: a bundled,
curated given-name -> sex dictionary (weighted English + German for the first
real tree) proposes sex for people who don't have it set. Deterministic, offline,
no model. Genuinely ambiguous names (Marion, Frances, Jordan, …) are excluded
from both sets so they're left for a human. Reuses the existing preview/apply
gender flow, so every guess is reviewed before saving.
No migration. 56 backend tests pass; frontend builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
A new per-tree Cleanup page (and cleanup_service + endpoints), each fix
preview-first per the propose-then-approve rule:
- Mark deceased by birth year: lists people born ≤ a cutoff (default 1930) not
already deceased; apply sets is_living=false for the ones you keep checked.
- Set sex from a source GEDCOM: upload the source .ged (it carries SEX); matches
by name and proposes sex only where it's missing — far more accurate than
guessing from first names. Review, then apply.
- Names that look broken: flags date-in-surname / date-in-given / no-surname /
packed given names, with inline editable given+surname; fix the checked ones.
No migration (uses existing columns). 55 backend tests pass (preview+apply for
all three); frontend builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
A blue ♂ (male) or pink ♀ (female) symbol now follows the person's name in the
detail header, using the same gender tints as the tree cards. Nothing shows when
sex is unknown.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- Theme is now class-based (.dark on <html>) with a System/Light/Dark toggle in
the sidebar, persisted to localStorage and applied pre-paint by an inline
script (no flash). Replaces the prefers-color-scheme-only behavior, so a phone
on a light OS theme can still choose dark and vice versa.
- New brand-derived --line token (Ink at 55%): a dark line on the light paper,
light on dark. The family-chart tree connectors had the library's default
white stroke and were invisible in light mode — now they use --line, as do
the pedigree brackets and the fan-chart sectors.
- Light/dark tokens use the exact brand palette (Ink/Muted flip; Bronze/Paper
constant).
Frontend only — no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- Family view gets a prominent "+ Add person" button that creates a person and
opens their page to fill in details (previously you could only add a person
via the empty-state form or by linking from another person).
- The person page's relationship picker (PersonCombobox) now offers
"+ Create '<typed name>'" when the person doesn't exist yet: it creates them,
links them in the chosen role (parent/child/partner/sibling), and jumps to
their new page to edit — no more create-then-go-back-and-link.
Frontend only — no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
New account_service + endpoints under /users/me:
- GET /me/export — zip of every owned tree (account.json + media blobs).
- POST /me/import — restore a backup into NEW trees (ids remapped, media
re-uploaded); non-destructive, never touches existing data.
- DELETE /me — soft-delete the user, their owned trees, and revoke sessions;
guarded by retyping the account email.
Settings page wires all three (export download, restore upload, delete with
typed-email confirmation). No migration — uses existing tables + soft-delete.
52 backend tests pass (export→restore round-trip + delete guards); frontend builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The Media model already carried person_id/event_id/source_id and the upload
route already accepted person_id — this surfaces it in the UI:
- Person page: a Media card lists media linked to that person, uploads new
files already linked ("Upload & link"), links existing unlinked media, and
unlinks.
- Media page: each item gets a person picker to link/unlink.
Frontend only — no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- Partnership life events (marriage/divorce/engagement) now attach to the
couple's relationship, not each person. The add-event form asks for the
spouse, finds-or-creates the partnership, and writes ONE event on it — shown
on both partners' pages ("· with <spouse>"), entered once. Event values
(RELI/OCCU detail) now render too.
- Family-view pedigree orders parents deterministically (father on top, mother
below, stable fallback when gender is unknown) instead of by which link was
created first.
Frontend only — no migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- Sidebar bottom-left now shows the signed-in user; clicking opens a menu with
Settings and Sign out. New /settings page: account info + change password
(POST /auth/change-password, re-verifies current password). Export/restore/
delete are stubbed there for the next pass.
- Per-tree default/home person: tree.home_person_id (migration) + TreeUpdate/
Read; the tree and family views open focused on it; the person page gets a
"Set as default" control and "Default person" badge. Cleared if that person
is deleted. Complements the account-level "this is me" link.
- Tree visualization now fills the content area (AppShell drops the max-width
column on the /tree route); other pages stay centered.
- Audit records are coerced JSON-safe (UUIDs/enums), so PATCHing UUID fields
like home_person_id audits cleanly.
50 backend tests pass; migration up/down verified; frontend builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
So a deploy never needs a manual `alembic upgrade head`:
- Backend image gains an entrypoint that runs `alembic upgrade head` before
uvicorn when RUN_MIGRATIONS=1 (set on the backend service). This self-migrates
even on a Watchtower in-place image swap, which doesn't re-run one-shot jobs.
- A one-shot `migrate` service covers the `docker compose up` path; backend and
worker depend on it completing, which also serializes it with the backend
entrypoint so alembic never runs concurrently. `upgrade head` is idempotent.
Activating this needs the updated compose on the host once (Watchtower only
swaps images, not the compose file / env). After that, migrations are automatic.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- Add a top-level "Import" entry to the sidebar and a global /import page, so
you can start a tree from a GEDCOM without first creating an empty one. The
import flow now picks its destination (new tree, or an existing one) — the
tree-scoped page reuses the same <GedcomImport> with a fixed destination and
keeps Export.
- Extract the sidebar chrome into <AppShell> and give small screens a working
menu: a hamburger opens the full sidebar as a slide-in drawer (it was just a
logo + "Trees" link before). Used by both /trees and /import.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Duplicate detection (the "merge / skip / overwrite" the user asked for):
- New POST /gedcom/preview dry-runs the file and flags incoming people that
resemble existing ones (name similarity via difflib + birth-year guard;
high/medium score). No writes.
- /gedcom/import takes default_action (new|skip|merge|overwrite) + per-xref
resolutions {xref: {action, target_id}}:
new create as a new person (current behavior)
skip link families to the existing person, copy nothing
merge attach the incoming names (as alternates), events, citations,
and notes onto the existing person
overwrite soft-delete the existing person, import the incoming one fresh
Relationship creation is deduped so a merge can't double an edge.
Richer record mapping (covers the user's repo's GEDCOM):
- Multiple NAME records honor their TYPE; _MARNM (and NICK) import as typed
alternate names — maiden stays primary, married becomes a "married" Name.
- RELI -> a "religion" event with the value in detail; OCCU/EDUC values too.
- NOTE -> person notes (and event notes); NOTE/RELI are no longer "unmapped".
- Export round-trips name TYPE.
Verified against the user's 2185-person export: 0 unmapped tags. 48 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Names (the genealogy standard: maiden name primary, married/alias as typed
alternates):
- Name model already supported multiple typed names; expose full CRUD —
NameCreate/Read/Update schemas, name_service (one-primary invariant,
promote-on-delete), nested /persons/{id}/names routes.
- Person page gains a Names card: add/edit/delete + "make primary", with a
curated name_type dropdown (birth/maiden, married, alias, nickname, …).
Self-person ("who am I"):
- users.self_person_id FK (use_alter for the users<->persons<->trees cycle)
+ migration; PATCH /users/me/self-person; "This is me" / "This is you"
on the person page. Soft-deleting the linked person clears it.
Deletion integrity (fixes the broken tree view):
- delete_person now soft-deletes the relationships touching the person, so no
dangling edges remain; family-chart also filters links to missing people.
- Optional cascade=true recursively deletes descendants (GEDCOM cleanup);
the person page asks "only this person" vs "with all descendants".
- DELETE returns {deleted: n}.
Family view surfaces "Not connected to anyone" so dangling people aren't lost.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- Tree page: add a "Find a person" search box that jumps the chart to a
match and rebuilds the hourglass (parents/grandparents/partner/children)
around them. Clicking any card recenters via family-chart's default
behavior (setAncestryDepth 3 / setProgenyDepth 2), syncing focus through
setAfterUpdate for the "Open profile" link.
- Person detail: replace the relationship "add" <select> with a
type-to-filter PersonCombobox so long people lists are searchable.
- Person detail: gender is now a Male/Female dropdown, not free text.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Closes the rule #8 gap at the API layer: PATCH endpoints + service updates for Tree (name/description/visibility), Source, Citation (page/detail/confidence), Relationship (qualifier/notes), and Media (title/attachment) — editor-gated and audited. Every core entity now has create/read/update/delete. Edit UIs for these land in the frontend batch. 37 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>
Person detail: an Edit form for name + gender + living status + privacy, and inline edit of each life event (type + structured date). Family view: the add-relative buttons now search existing people (link the real person) or create new — preventing duplicate spouses/parents — and adding a child to someone with one spouse links both parents.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Justin Paul <[email protected]>