Version. v0.1
Date. 2026-04-26
Author. Claude (drafting) / Marvin Percival (approving).
Target. /Users/dunin7/loomworks (substrate) and /Users/dunin7/loomworks-ui (frontend) on DUNIN7-M4 (MacMini).
Baseline reference. Substrate: tag phase-13a-totp-authenticator at commit b942297. 905 tests, 2 skipped. Frontend: tag phase-13a-totp-authenticator at commit 3f096fa. Lint + tsc clean.
Priority. Standard (sequential phase).
Confidential. Internal DUNIN7.
Supersedes. No prior Phase 14 CR — v0.1 is the first.
Companion to. loomworks-phase-14-scoping-note-v0_1.md (five confirmed decisions S1–S5); loomworks-person-layer-discovery-v0_2.md (eleven settled decisions); current-status-manifest-v0_11.md; methodology what-dunin7-is-building-v0_17.docx; loomworks-brand-README-v0_1.md → loomworks-design-md-v0_1.md → loomworks-brand-guide-v0_15.html (brand system — the welcome page is a ceremonial surface).
Status. Pre-execution CR. Ready for Operator review and approval.
Phase 14 introduces the person as a first-class entity in the Loomworks substrate. After Phase 14 lands:
The deliverables are:
person, webauthn_credential, membership, membership_designation, recovery_code.visibility on the engagement table (default 'private').py_webauthn dependency for WebAuthn registration and authentication ceremonies.Projected new test count: approximately 120–150 new substrate tests. Frontend: lint + tsc clean.
Why the person layer now. Phase 13 built a single-Operator product. The substrate has no concept of a person that exists outside of engagement-scoped contributor rows. No one else can create an account, receive an invitation, or participate. The person layer is the foundation for multi-user Loomworks — everything else (invitation, engagement creation through the UI, domain declaration, discoverable engagements) stands on it.
Why real WebAuthn now, not incremental. The dev-token bridge was always labeled as temporary. Phase 14 retires it. WebAuthn passkeys are the primary credential for Loomworks — they're phishing-resistant, they don't require the person to remember or type anything, and they integrate with the platform credential managers (iCloud Keychain, 1Password, Windows Hello) that people already use. Doing this incrementally (passkeys optional alongside dev tokens) would mean maintaining two auth paths, which doubles the security surface for no gain.
Why delete the contributor rows, not soft-delete. The event log records every migration event. Keeping dead human contributor rows alongside live membership rows creates ambiguity about which table is authoritative for human identity. The contributor table remains for agents (kind='agent'). For humans, the answer is always the membership table. Clean separation.
Why a visibility column now when discoverable behavior is deferred. Schema migrations are cheap; retroactive migrations after data exists are not. Adding the column now (default 'private', no behavior) means Phase 15 or later can build discoverable-engagement behavior without a migration that touches existing engagement rows.
phase-13a-totp-authenticator (commit b942297). 905 tests, 2 skipped.phase-13a-totp-authenticator (commit 3f096fa). Lint + tsc clean.commit_authority: true, bearer tokens, TOTP configured on the admin engagement's contributor row.py_webauthn installable via pip install py_webauthn --break-system-packages (or uv add py_webauthn).totp_secret column on contributor table, four TOTP endpoints, pyotp dependency. The TOTP mechanism carries forward; the secret storage location migrates from contributor to person.Before any work begins, CC confirms:
uv run pytest -v shows 905 passed, 2 skipped.commit_authority: true.totp_secret.py_webauthn installs cleanly.localhost is the development host (WebAuthn secure context).Five decisions confirmed during scoping (S1–S5). Eleven settled decisions from the Discovery record (Decisions 1–11) absorbed as design input. Not subject to relitigation.
S1 — Scope: option (a). Person + passkey + migration only. No invitation flow, no engagement creation via UI, no domain declaration. Loomworks engagement induction deferred to Phase 15.
S2 — Schema. Five new tables (person, webauthn_credential, membership, membership_designation, recovery_code). Contributor table retained for agents. Human contributor rows deleted after migration. Visibility column on engagement table.
S3 — WebAuthn library: py_webauthn. Library handles both ceremonies. Two-step provisioning pattern.
S4 — Domain declaration agent: deferred. Domain Expert designation flag available on membership_designation from day one. Declaration flow and domain table are future-phase work.
S5 — Deployment: M4, localhost only. WebAuthn treats localhost as secure context. M5 LAN access deferred.
Two migrations in sequence:
Migration A — New tables. Creates person, webauthn_credential, membership, membership_designation, recovery_code. All UUIDs as primary keys (consistent with existing schema conventions).
Migration B — Engagement visibility. Adds visibility column (TEXT, default 'private', NOT NULL) to the engagement table.
Migration filenames follow the four-digit convention. The current highest migration number should be confirmed at Step 0.
person
CREATE TABLE person (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
display_name TEXT NOT NULL,
email TEXT,
mobile TEXT,
totp_secret BYTEA, -- Fernet-encrypted, same scheme as Phase 13A
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
webauthn_credential
CREATE TABLE webauthn_credential (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES person(id),
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
transports JSONB, -- e.g. ['internal', 'hybrid']
display_name TEXT, -- optional label: "MacBook Pro passkey"
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_webauthn_credential_person ON webauthn_credential(person_id);
membership
CREATE TABLE membership (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES person(id),
engagement_id UUID NOT NULL REFERENCES engagement(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (person_id, engagement_id)
);
CREATE INDEX idx_membership_person ON membership(person_id);
CREATE INDEX idx_membership_engagement ON membership(engagement_id);
membership_designation
CREATE TABLE membership_designation (
membership_id UUID NOT NULL REFERENCES membership(id) ON DELETE CASCADE,
designation TEXT NOT NULL,
PRIMARY KEY (membership_id, designation)
);
Seed values: 'operator', 'contributor', 'domain_expert'. Enforced by application logic, not a database enum (extensibility — adding a fourth designation is an insert, not a migration).
recovery_code
CREATE TABLE recovery_code (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES person(id),
code_hash TEXT NOT NULL, -- bcrypt or argon2 hash
used_at TIMESTAMPTZ, -- NULL = unused
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_recovery_code_person ON recovery_code(person_id);
ALTER TABLE engagement ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
No check constraint — application logic validates. Values today: 'private'. Future: 'discoverable'.
class Person(BaseModel):
id: UUID
display_name: str
email: str | None = None
mobile: str | None = None
created_at: datetime
updated_at: datetime
The totp_secret is never exposed through the model — it's an internal storage detail accessed through dedicated TOTP endpoints.
create_person(*, display_name: str, email: str | None, mobile: str | None, session: AsyncSession) -> Person
Creates the person row. Does not create credentials — those are created through separate WebAuthn and TOTP endpoints. The signup orchestration (Section 7.3) coordinates the full flow.
py_webauthn — handles challenge generation, attestation verification, assertion verification. The substrate does not implement WebAuthn cryptographic operations directly.
Environment variables:
WEBAUTHN_RP_ID=localhost
WEBAUTHN_RP_NAME=Loomworks
WEBAUTHN_RP_ORIGIN=http://localhost:3000
These are read once at startup and passed to py_webauthn functions.
The signup flow is a multi-step orchestration. The substrate provides endpoints for each step; the frontend orchestrates them in sequence.
Step 1 — Begin registration.
POST /auth/signup/begin
Body: { "display_name": "...", "email": "...|null", "mobile": "...|null" }
Response: { "registration_id": "...", "options": { /* WebAuthn PublicKeyCredentialCreationOptions */ } }
Creates a temporary registration record (in-memory or short-lived database row) holding the challenge and the person details. Returns WebAuthn registration options for navigator.credentials.create(). Does not create a person record.
The registration_id is a server-side reference for correlating subsequent steps. It expires after a short window (e.g. 5 minutes).
Step 2 — Complete passkey registration.
POST /auth/signup/passkey
Body: { "registration_id": "...", "credential": { /* AuthenticatorAttestationResponse */ } }
Response: { "totp_uri": "...", "totp_secret": "..." }
Verifies the attestation response via py_webauthn.verify_registration_response(). On success, stores the verified credential data in the pending registration and returns the TOTP provisioning URI and secret for QR display. Does not create a person record yet.
Step 3 — Verify TOTP.
POST /auth/signup/totp-verify
Body: { "registration_id": "...", "code": "..." }
Response: { "recovery_codes": ["...", "...", ...] }
Verifies the TOTP code against the pending registration's secret using pyotp. On success:
person record.webauthn_credential record.totp_secret on the person record.recovery_code table.All writes are atomic (single database transaction).
Step 4 — Add additional passkey (optional, post-signup).
POST /auth/passkey/add/begin
Headers: session token required
Response: { "options": { /* WebAuthn PublicKeyCredentialCreationOptions */ } }
POST /auth/passkey/add/complete
Headers: session token required
Body: { "credential": { /* AuthenticatorAttestationResponse */ } }
Response: { "credential_id": "..." }
Authenticated person can add additional passkeys at any time. Two-step provisioning pattern: begin returns challenge, complete verifies and stores. Product surface encourages registering at least two passkeys on separate devices.
Step 1 — Begin authentication.
POST /auth/login/begin
Body: { } // or { "email": "..." } for user hint (optional)
Response: { "authentication_id": "...", "options": { /* WebAuthn PublicKeyCredentialRequestOptions */ } }
If no user hint is provided, the options include no allowCredentials — the browser presents all available passkeys (discoverable credential / resident key flow). If a user hint is provided, the options include allowCredentials filtered to that person's registered credentials.
Creates a temporary authentication record holding the challenge.
Step 2 — Complete authentication.
POST /auth/login/complete
Body: { "authentication_id": "...", "credential": { /* AuthenticatorAssertionResponse */ } }
Response: { "requires_totp": true, "partial_session": "..." }
Verifies the assertion response via py_webauthn.verify_authentication_response(). Updates sign_count on the credential record. Issues a partial session (passkey verified, TOTP not yet verified). Does not grant full access.
Step 3 — TOTP verification.
POST /auth/login/totp-verify
Headers: partial session token required
Body: { "code": "..." }
Response: { "session": "..." }
Verifies the TOTP code against the person's stored secret. On success, upgrades the partial session to a full session. The full session grants access to all engagements the person has membership in.
Alternative: recovery code.
POST /auth/login/recovery
Headers: partial session token required
Body: { "code": "..." }
Response: { "session": "..." }
Verifies a recovery code against the person's stored hashes. On success, marks the code as used (used_at set), upgrades the session. Each code works once.
The session replaces the multi-token cookie bridge from Phase 13. A session is person-scoped, not engagement-scoped.
class SessionPayload(BaseModel):
person_id: UUID
totp_verified: bool
created_at: datetime
expires_at: datetime
The session is stored in an HTTP-only secure cookie, signed with LOOMWORKS_SECRET_KEY. The session carries the person_id; engagement access is determined by querying the membership table at each request.
The middleware replaces the per-engagement bearer token check:
totp_verified is true (reject partial sessions on protected routes).person_id from session./engagements/{id}/...): query membership for (person_id, engagement_id). If no membership exists, 403.membership_designation. If the required designation is absent, 403.
The middleware attaches the resolved Person and (for engagement-scoped endpoints) the Membership to the request context, replacing the current contributor resolution.
Ten codes generated at signup, each 8 alphanumeric characters (uppercase + digits, no ambiguous characters: no 0/O, 1/I/L). Stored as bcrypt hashes. Each code is single-use — after verification, used_at is set and the code is no longer valid.
The person sees the codes once at signup and is responsible for storing them. The product surface says:
Recovery codes are a TOTP alternative — they complete the second factor after passkey authentication, same as TOTP.
async def create_membership(
*, person_id: UUID, engagement_id: UUID, designations: list[str],
session: AsyncSession
) -> Membership
async def get_person_memberships(
*, person_id: UUID, session: AsyncSession
) -> list[Membership]
async def get_engagement_memberships(
*, engagement_id: UUID, session: AsyncSession
) -> list[Membership]
async def add_designation(
*, membership_id: UUID, designation: str, session: AsyncSession
) -> None
async def remove_designation(
*, membership_id: UUID, designation: str, session: AsyncSession
) -> None
async def delete_membership(
*, membership_id: UUID, session: AsyncSession
) -> None
class Membership(BaseModel):
id: UUID
person_id: UUID
engagement_id: UUID
designations: list[str]
created_at: datetime
updated_at: datetime
Application-level validation. Currently accepted values: 'operator', 'contributor', 'domain_expert'. Unrecognized designations are rejected with 400. The accepted set is a constant, not a database enum — extending it is a code change, not a migration.
Actions restricted to the 'operator' designation on the relevant engagement:
The middleware checks designation before dispatching to the handler.
GET /engagements/{id}/memberships — list memberships for engagement
GET /engagements/{id}/memberships/{mid} — get single membership
POST /engagements/{id}/memberships/{mid}/designations — add designation (Operator only)
DELETE /engagements/{id}/memberships/{mid}/designations/{designation} — remove designation (Operator only)
DELETE /engagements/{id}/memberships/{mid} — remove membership (Operator only)
GET /me — get current person + all memberships
GET /me/memberships — get current person's memberships
The /me endpoints use the session's person_id. No engagement scope needed.
bearer_token_hash column on contributor rows for humans.
All engagement-scoped endpoints (/engagements/{id}/...) currently extract a bearer token from the Authorization header. After Phase 14:
The middleware must support both paths: session cookie for humans, bearer token for agents. A request with a valid session cookie is a human; a request with a valid Authorization header is an agent. If both are present, session cookie takes precedence.
GET /engagements (Residue 6) remains unauthenticated in Phase 14. This is acceptable for localhost-only deployment. The endpoint's future auth requirement is noted but not implemented.
The data migration runs as a one-time script, not as an Alembic migration (it's data, not schema). It can also be encoded as a step in the CR's order of operations.
person_created, membership_created (×4), contributor_migrated (×4), contributor_deleted (×4).After the Operator has a passkey registered and a working session:
navigator.credentials.get() instead of the dev-token bridge.
The bearer_token_hash column on contributor rows is no longer read for human authentication. Agent contributor rows retain their bearer tokens. The column itself is not dropped in this phase — dropping it requires confirming no agent code depends on the column's presence on human rows. A future migration can clean it up.
/signup — multi-step signup page.
The signup page is a ceremonial surface (vertical lockup register, consistent with the landing page). Steps rendered sequentially, not as a wizard with a progress bar:
/auth/signup/begin./auth/signup/passkey./auth/signup/totp-verify.Credential guidance. The signup page includes clear, non-dismissable text:
The guidance about adding a second passkey appears after the first passkey is registered: "You can add more passkeys later from your account settings."
Per the Phase 13A lesson (manifest Entry 25, named pattern): the signup flow is server-rendered. Client interactivity is limited to the WebAuthn browser API calls (navigator.credentials.create()) and the auto-submit on TOTP sixth digit. Form submissions use server actions or POST to API route handlers. No useEffect-dependent flows.
The signup page is a ceremonial surface — vertical lockup, same register as the landing page. Render against the brand guide HTML (opened in a browser) side-by-side during development. Do not rely on DESIGN.md token values alone (Entry 24 lesson).
Landing page → click "Sign in with passkey" → dev-token bridge → authenticator verify → dashboard.
Landing page → click "Sign in with passkey" → navigator.credentials.get() (real WebAuthn) → /auth/login/begin + /auth/login/complete → authenticator verify (TOTP or recovery code) → /auth/login/totp-verify → dashboard (returning user) or welcome page (first time).
The session response from /auth/login/totp-verify includes a flag indicating whether this is the person's first login. If first time → redirect to /welcome. If returning → redirect to /dashboard.
The first-time flag is determined by whether the person has any memberships beyond the automatic ones (none in Phase 14, since automatic Loomworks membership is Phase 15) — or simply by a first_login_at timestamp on the person record that is NULL until the first successful login.
The landing page gains a "Create account" link (or the "Sign in with passkey" area includes a "New to Loomworks? Create account" secondary action) that navigates to /signup.
/welcome — the person's first arrival after signup.
Ceremonial surface (vertical lockup). Not the dashboard. Content:
/dashboard.Per the "only show what is available" UI principle (from the UI discovery settled designs): if a door can't be opened, it doesn't appear on the surface. In Phase 14, only the dashboard door is active, so only the dashboard door appears. The welcome page is still valuable — it explains Loomworks and designations and gives the person context before they see the (possibly empty) dashboard.
Vertical lockup. The brand guide's landing page composed context is the reference. The welcome page shares the register but has different content. Render against the brand guide side-by-side.
The dashboard currently reads engagement data via GET /engagements (unauthenticated) and contributor data via GET /engagements/{id}/contributors/me. After Phase 14:
GET /me/memberships.commit_authority) reads from the membership's designations: "Operator" if 'operator' in designations, "Contributor" if 'contributor' in designations, etc. Multiple designations are displayed (e.g., "Operator · Domain Expert").
The engagement overview page (/engagement/[id]) currently reads contributor data for the authenticated user. After Phase 14, it reads membership data. The page's content (seed, Memory, shapings, renders) is unchanged — it's the auth and identity resolution that changes.
create_test_person helper that creates a person record with a mock passkey credential and TOTP secret.create_test_membership helper that creates a membership with specified designations.py_webauthn's test utilities for mock credentials and assertions.Lint + tsc clean. No test framework in Phase 14 (consistent with Phase 13 — a test framework for the frontend is a future-phase item).
| File | Tests (projected) |
|------|-------------------|
| test_person_model.py | 8 |
| test_person_creation.py | 10 |
| test_webauthn_registration.py | 15 |
| test_webauthn_authentication.py | 15 |
| test_recovery_codes.py | 10 |
| test_membership_model.py | 8 |
| test_membership_designations.py | 12 |
| test_membership_http.py | 15 |
| test_auth_middleware.py | 15 |
| test_session_model.py | 8 |
| test_data_migration.py | 10 |
| test_person_me_endpoints.py | 8 |
| Total (projected) | ~134 |
Per Phase 9 Finding F discipline: per-file projections are the authoritative shape; the aggregate may drift ~20% at execution.
Each scoping decision has at least one test that would fail if the decision were reversed:
Scenario A — Full signup flow. Create person → register passkey → setup TOTP → verify TOTP → receive recovery codes → session issued → welcome page accessible.
Scenario B — Sign-in with passkey + TOTP. Existing person → begin auth → passkey assertion → partial session → TOTP verify → full session → dashboard accessible.
Scenario C — Sign-in with recovery code. Existing person → passkey assertion → partial session → recovery code → full session → code marked used → same code rejected on retry.
Scenario D — Membership authorization. Person with membership on engagement A can access engagement A endpoints. Person without membership on engagement A gets 403. Person with 'operator' designation can add designations; person with 'contributor' only cannot.
Scenario E — Data migration. Person record created. Four memberships with 'operator' designation. Old contributor rows deleted. TOTP secret on person record matches the original. Event log records all migration events.
Scenario F — Agent auth unchanged. Agent contributor with bearer token can still authenticate. Agent access is unaffected by the person-layer changes.
Scenario G — Multiple passkeys. Person registers a second passkey. Either passkey authenticates successfully.
Auto-mode posture: Steps 1–3 auto, Checkpoint A. Steps 4–6 auto, Checkpoint B. Steps 7–9 auto, Checkpoint C (interactive — migration requires Operator's browser). Steps 10–13 auto, Checkpoint D (final).
Step 0 — Pre-flight and CR archival.
Archive this CR to docs/phase-crs/phase-14-cr-person-layer-v0_1.md. Run pre-flight checks (Section 3.1). Confirm baseline. Create branch phase-14-person-layer.
Commit: Phase 14 step 0: CR archival and branch creation.
Step 1 — Schema migrations.
Create Migration A (five new tables) and Migration B (engagement visibility column). Apply migrations.
Verification: migrations apply cleanly. uv run pytest -v still green (905 passed).
Commit: Phase 14 step 1: schema migrations for person layer.
Step 2 — Person model and creation logic.
Add Person model to types. Implement create_person, get_person_by_id, get_person_by_email. Write test_person_model.py and test_person_creation.py.
Verification: person tests green. uv run pytest -v still green overall.
Commit: Phase 14 step 2: person model and creation.
Step 3 — WebAuthn registration.
Add py_webauthn dependency. Implement begin_registration, complete_registration, add_passkey_begin, add_passkey_complete. Implement the signup orchestration endpoints (/auth/signup/begin, /auth/signup/passkey, /auth/signup/totp-verify). Write test_webauthn_registration.py.
Verification: registration tests green. uv run pytest -v still green.
Commit: Phase 14 step 3: WebAuthn registration and signup flow.
Checkpoint A — Person creation and passkey registration are working. Operator confirms before auth flow lands.
Step 4 — WebAuthn authentication.
Implement begin_authentication, complete_authentication. Implement login endpoints (/auth/login/begin, /auth/login/complete). Write test_webauthn_authentication.py.
Verification: authentication tests green. uv run pytest -v still green.
Commit: Phase 14 step 4: WebAuthn authentication.
Step 5 — Session model and auth middleware.
Implement SessionPayload, session cookie creation and verification. Implement auth middleware (Section 7.6) — session extraction, person resolution, membership check, designation check. Implement TOTP verification endpoint (/auth/login/totp-verify) and recovery code endpoint (/auth/login/recovery). Write test_session_model.py, test_auth_middleware.py, test_recovery_codes.py.
Verification: session and middleware tests green. uv run pytest -v still green.
Commit: Phase 14 step 5: session model and auth middleware.
Step 6 — Membership and designations.
Implement membership CRUD (Section 9.1). Implement HTTP endpoints (Section 9.5). Implement /me and /me/memberships endpoints. Write test_membership_model.py, test_membership_designations.py, test_membership_http.py, test_person_me_endpoints.py.
Verification: membership tests green. uv run pytest -v still green.
Commit: Phase 14 step 6: membership and designations.
Checkpoint B — All substrate models, endpoints, and middleware are working. Operator confirms before migration.
Step 7 — Rewire existing endpoints.
Update all engagement-scoped endpoint handlers to use the new auth middleware (session → person → membership) instead of bearer token extraction. Ensure agent auth (bearer token) still works alongside person auth (session). Update GET /engagements to remain unauthenticated (Residue 6 — accepted for localhost).
Verification: all 905 existing tests still pass (they may need fixture updates to use the new auth mechanism). uv run pytest -v green.
Commit: Phase 14 step 7: rewire existing endpoints to person auth.
Step 8 — Data migration.
Run the migration script (Section 11). Creates person record, four memberships, four operator designations. Deletes four human contributor rows. Records events. This step is interactive — the passkey registration requires the Operator's browser.
Verification: test_data_migration.py green. Person record exists. Four memberships exist with 'operator' designation. Old contributor rows are gone. TOTP secret migrated. Event log records migration events.
Commit: Phase 14 step 8: data migration.
Step 9 — Retire dev-token bridge.
Remove dev-token generation and verification code. Remove multi-token cookie logic. Clean up any remaining references to the old auth model.
Verification: uv run pytest -v green. No references to dev tokens remain in the codebase (grep confirms).
Commit: Phase 14 step 9: retire dev-token bridge.
Checkpoint C — Substrate complete. Operator confirms person record, memberships, and passkey auth work before frontend work begins. This checkpoint is interactive — the Operator tests passkey sign-in via curl or a minimal browser test.
Step 10 — Sign-in flow rewire.
Update the landing page's "Sign in with passkey" button to call navigator.credentials.get() → /auth/login/begin → /auth/login/complete. Update the authenticator verify page to call /auth/login/totp-verify. Update routing: first-time → /welcome, returning → /dashboard. Add "Create account" link to landing page. Remove dev-token bridge client code.
Verification: lint + tsc clean. Operator can sign in with passkey + TOTP from the browser.
Commit (frontend repo): Phase 14 step 10: sign-in flow rewire.
Step 11 — Signup flow.
Build the /signup page (Section 12). Four-step flow: identity → passkey → authenticator → recovery codes. Server-rendered. Brand-compliant (vertical lockup, ceremonial register). Render against brand guide side-by-side.
Verification: lint + tsc clean. Operator can complete signup flow from the browser (creates a second person for testing).
Commit (frontend repo): Phase 14 step 11: signup flow.
Step 12 — Welcome page.
Build the /welcome page (Section 14). Ceremonial surface (vertical lockup). Dashboard door only (no create-engagement or invitation-code doors in Phase 14). Brand-compliant.
Verification: lint + tsc clean. After signup, person is redirected to welcome page. Dashboard link works.
Commit (frontend repo): Phase 14 step 12: welcome page.
Step 13 — Dashboard and engagement overview updates.
Update dashboard to read from /me/memberships instead of contributor endpoints. Update role labels on engagement cards. Update header name badge. Update engagement overview page auth.
Verification: lint + tsc clean. Dashboard shows engagement cards with correct designations for the signed-in person.
Commit (frontend repo): Phase 14 step 13: dashboard and engagement overview updates.
Checkpoint D — Final. Both repos green. Operator signs in with passkey, sees dashboard, all four engagements visible with Operator designation. A second person can sign up, sees welcome page, proceeds to empty dashboard. Tag both repos.
Phase 14 is accepted when:
On acceptance: tag both repos as phase-14-person-layer. Write implementation notes.
visibility column present, all rows 'private'.GET /engagements still unauthenticated. Acceptable for localhost.bearer_token_hash column. Still present on the contributor table. Not read for human auth. Can be cleaned up in a future migration.GET /engagements unauthenticated. Residue 6 carried forward.py_webauthn — WebAuthn registration and authentication ceremonies.pyotp — TOTP verification. Same dependency, now used against person.totp_secret instead of contributor.totp_secret.cryptography (Fernet) — encryption for totp_secret. Same scheme, same key.bcrypt — recovery code hashing. May already be present; if not, add.
No new frontend dependencies anticipated. navigator.credentials is a browser-native API.
> Read the Phase 14 CR at ~/Downloads/phase-14-cr-person-layer-v0_1.md. This is the person layer — new tables (person, webauthn_credential, membership, membership_designation, recovery_code), real WebAuthn passkey auth via py_webauthn, person-scoped sessions replacing the multi-token bridge, and a data migration for the existing Operator. Two repos: substrate (/Users/dunin7/loomworks) and frontend (/Users/dunin7/loomworks-ui). Substrate baseline: tag phase-13a-totp-authenticator at commit b942297, 905 tests. Frontend baseline: same tag at commit 3f096fa. Start with Step 0: archive the CR to docs/phase-crs/, run pre-flight checks, confirm baseline, create the branch.
GET /engagements authentication (Residue 6).v0.1 (2026-04-26). First version. No prior Phase 14 CR.
DUNIN7 — Done In Seven LLC — Miami, Florida Phase 14 — Person layer — CR-2026-026 — v0.1 — 2026-04-26