DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/phase-14-person-layer/phase-14-cr-person-layer-v0_1.md

DUNIN7-M4 — INFRASTRUCTURE CHANGE REQUEST

CR-2026-026 — Phase 14: Person Layer (v0.1)

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.mdloomworks-design-md-v0_1.mdloomworks-brand-guide-v0_15.html (brand system — the welcome page is a ceremonial surface). Status. Pre-execution CR. Ready for Operator review and approval.


Contents


1. Executive summary

Phase 14 introduces the person as a first-class entity in the Loomworks substrate. After Phase 14 lands:

The deliverables are:

Projected new test count: approximately 120–150 new substrate tests. Frontend: lint + tsc clean.


2. Rationale

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.


3. Prerequisites

3.1 Pre-flight checks

Before any work begins, CC confirms:

  1. uv run pytest -v shows 905 passed, 2 skipped.
  2. The four engagements exist with correct IDs per manifest v0.11.
  3. The Operator's contributor rows exist on all four engagements with commit_authority: true.
  4. The admin engagement contributor row has a non-null totp_secret.
  5. py_webauthn installs cleanly.
  6. localhost is the development host (WebAuthn secure context).

4. Construction decisions this CR closes

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.


5. Schema: five new tables and one column addition

5.1 Alembic migrations

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.

5.2 Table definitions

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);

5.3 Engagement table addition


ALTER TABLE engagement ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';

No check constraint — application logic validates. Values today: 'private'. Future: 'discoverable'.


6. Person model and creation

6.1 Pydantic model


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.

6.2 Creation logic

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.


7. WebAuthn registration and authentication

7.1 Dependency

py_webauthn — handles challenge generation, attestation verification, assertion verification. The substrate does not implement WebAuthn cryptographic operations directly.

7.2 RP configuration

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.

7.3 Signup flow (registration)

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:

  1. Creates the person record.
  2. Creates the webauthn_credential record.
  3. Stores the encrypted totp_secret on the person record.
  4. Generates recovery codes (10 codes, 8 alphanumeric characters each), stores bcrypt hashes in recovery_code table.
  5. Creates a session for the person.
  6. Returns the plaintext recovery codes (displayed once, never retrievable again).

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.

7.4 Sign-in flow (authentication)

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.

7.5 Session model

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.

7.6 Auth middleware

The middleware replaces the per-engagement bearer token check:

  1. Extract session cookie.
  2. Verify signature and expiry.
  3. Verify totp_verified is true (reject partial sessions on protected routes).
  4. Resolve person_id from session.
  5. For engagement-scoped endpoints (/engagements/{id}/...): query membership for (person_id, engagement_id). If no membership exists, 403.
  6. For endpoints requiring specific designations (e.g. Operator-only actions): query 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.


8. Recovery codes

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.


9. Membership and designations model

9.1 Membership CRUD


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

9.2 Membership model


class Membership(BaseModel):
    id: UUID
    person_id: UUID
    engagement_id: UUID
    designations: list[str]
    created_at: datetime
    updated_at: datetime

9.3 Designation validation

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.

9.4 Operator-only actions

Actions restricted to the 'operator' designation on the relevant engagement:

The middleware checks designation before dispatching to the handler.

9.5 HTTP endpoints


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.


10. Auth model: from multi-token bridge to person-scoped session

10.1 What is being retired

10.2 What replaces it

10.3 Impact on existing endpoints

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.

10.4 Unauthenticated endpoints

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.


11. Data migration

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.

11.1 Migration steps

  1. Create person record.
  1. Create four membership rows. One per engagement, each referencing the new person record.
  1. Create four membership_designation rows. Each membership gets designation 'operator'.
  1. Register a passkey. The Operator must complete a WebAuthn registration ceremony during migration. The migration script or a post-migration setup step triggers the registration flow. This is interactive — it requires the browser.
  1. Delete the four human contributor rows. After membership rows are confirmed correct.
  1. Record migration events. Write to the event log: person_created, membership_created (×4), contributor_migrated (×4), contributor_deleted (×4).

11.2 Dev-token bridge retirement

After the Operator has a passkey registered and a working session:

11.3 Bearer token retirement (humans only)

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.


12. Frontend: signup flow

12.1 New surfaces

/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:

  1. Identity. Name (required), email (optional), mobile (optional). Clear explanation: "Without email or mobile, no one can invite you by lookup and the system can't reach you." Submit → calls /auth/signup/begin.
  1. Passkey. Browser passkey creation prompt fires automatically after Step 1 succeeds. On success → calls /auth/signup/passkey.
  1. Authenticator. QR code displayed (same component as Phase 13A setup page, adapted for signup context). Person scans with authenticator app. Enters first six-digit code. Auto-submit on sixth digit. On success → calls /auth/signup/totp-verify.
  1. Recovery codes. Displayed in a grid. "Store these in a secure place. Each code can be used once. If you lose all credentials, your account cannot be recovered." Button: "I've saved my recovery codes" → redirects to welcome page.

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."

12.2 Server-render by default

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.

12.3 Brand compliance

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).


13. Frontend: sign-in flow rewire

13.1 Current flow (being replaced)

Landing page → click "Sign in with passkey" → dev-token bridge → authenticator verify → dashboard.

13.2 New flow

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).

13.3 First-time vs returning

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.

13.4 "Create account" link

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.


14. Frontend: welcome page

14.1 Surface

/welcome — the person's first arrival after signup.

Ceremonial surface (vertical lockup). Not the dashboard. Content:

  1. "Create an engagement" — disabled/absent in Phase 14, with a note that this is coming. (Per the "only show what is available" UI principle: this door should not appear at all in Phase 14. It appears when the capability is built.)
  2. "Enter an invitation code" — same treatment. Not available yet, not shown.
  3. "Go to the dashboard" — active. Links to /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.

14.2 Brand compliance

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.


15. Frontend: dashboard and engagement overview updates

15.1 Dashboard

The dashboard currently reads engagement data via GET /engagements (unauthenticated) and contributor data via GET /engagements/{id}/contributors/me. After Phase 14:

15.2 Engagement overview

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.


16. Test infrastructure

16.1 Substrate test patterns

16.2 Frontend test patterns

Lint + tsc clean. No test framework in Phase 14 (consistent with Phase 13 — a test framework for the frontend is a future-phase item).


17. Acceptance test suite

17.1 Per-file test projections

| 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.

17.2 Construction-decision coverage

Each scoping decision has at least one test that would fail if the decision were reversed:

17.3 Key acceptance scenarios

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.


18. Order of operations (steps with checkpoints)

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).

Substrate steps

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.

Frontend steps

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.


19. Acceptance gate

Phase 14 is accepted when:

  1. Substrate: all tests pass (905 existing + ~134 new ≈ 1039, ±20%).
  2. Frontend: lint + tsc clean.
  3. The Operator can sign in with passkey + TOTP and see the dashboard with four engagements.
  4. A new person can sign up (name, passkey, TOTP, recovery codes), land on the welcome page, and proceed to an empty dashboard.
  5. The new person cannot access engagement-scoped endpoints (no membership → 403).
  6. Agent auth (bearer token) still works on engagement-scoped endpoints.
  7. No dev-token, multi-token cookie, or bearer-token-for-humans code remains in either codebase.
  8. All migration events recorded in the event log.

On acceptance: tag both repos as phase-14-person-layer. Write implementation notes.


20. Post-CR state

New residues anticipated


21. Dependencies and related changes

21.1 New dependencies (substrate)

21.2 Existing dependencies carried forward

21.3 Frontend dependencies

No new frontend dependencies anticipated. navigator.credentials is a browser-native API.


22. Kickoff prompt for the Claude Code session

> 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.


23. What this CR does not specify (deferred to later phases)


24. Changes from prior versions

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