Alignment audit — Screens → API → ERD → Schema → FE

Generated 2026-05-11, end of the auth-baseline / tenancy-spine arc. Inputs: docs/artifacts/screens.html (14-screen architecture), app/swagger/v1/swagger.yaml, app/config/routes.rb, app/db/schema.rb, docs/05-design/entity-relationship-model.md, tenantmate-app/src/lib/api/.

Three things to fix before FE → BE wiring kicks off across more than the three live screens:

  1. OpenAPI ProblemDetails schema doesn't match the live {error: {code, http_status, metadata}} failure envelope. FE can't branch on deny codes off generated types.
  2. GET /me returns the bare Account; the Principal triple (account, active_grant, active_tenancy_membership) shipped in #355 is invisible to the FE.
  3. 41 of 47 OpenAPI paths have no live Rails route. Most screens beyond Auth / Property Report / pre-tenancy Dashboard need a pack carve-out (migration + controller + policy) before any wiring is meaningful.

Phase 0 fixes (1) and (2) in a single small PR. Everything else is screen-by-screen pack carve-out at the cadence we've been keeping.

Layer state — by the numbers

Screens

13

Canonical screen architecture v2. 3 planes (Workflow / Events / Information) + 4 cross-cutting surfaces.

OpenAPI paths

47

73 operations across 66 component schemas. Contracts-first via rswag --dry-run; most are documented but not yet routed.

Live Rails routes

6

GET /me, POST /me/active_role_grant, POST /me/active_tenancy_membership, GET / GET-by-id / POST /property_reports. That's it.

DB tables

13

5 Rodauth aux + accounts + audits + role_grants + properties + tenancies + tenancy_memberships + tenancy_parties + property_reports. rake docs:erd_lint green.

ERD entities

~80

~70 still Architectural; awaiting pack carve-out. Each pack lands the migration, model, policy, controller in one PR.

How a request flows — FE → BE

A live walk-through of POST /api/v1/property_reports (the Verify-before-let create endpoint that's shipped today). Same shape applies to every authenticated endpoint — only the action body and the guard chain vary by surface.

1 · Front-end call
tenantmate-app/src/lib/api/client.ts

One thin fetch wrapper. Cookies carry auth (credentials: "include"); X-Requested-With: XMLHttpRequest on state-changing requests is the CSRF defence pair. ApiError exposes isUnauthorized / isForbidden / isUnprocessable predicates so screens key UI off the status.

await api.createPropertyReport({ address_query: "M14 5JT" });
// → fetch("/api/v1/property_reports", {
//     method: "POST",
//     credentials: "include",
//     headers: { "X-Requested-With": "XMLHttpRequest", ... },
//     body: '{"property_report":{"address_query":"M14 5JT"}}' })
2 · Rails routing
app/config/routes.rb

The whole API is namespaced under /api/v1 with format: :json. Today: me + me/active_role_grant + me/active_tenancy_membership + property_reports (index / show / create). Every other path in the OpenAPI surface is contract-only (no route here yet — see G3).

namespace :api, defaults: { format: :json } do
  namespace :v1 do
    resource :me, only: :show, controller: "me" do
      post :active_role_grant, to: "me#set_active_role_grant"
      post :active_tenancy_membership, to: "active_tenancy_memberships#create"
    end
    resources :property_reports, only: [:index, :show, :create]
  end
end
3 · Controller declares its guard pipeline
app/packs/core/app/controllers/api/base_controller.rb · app/packs/property_due_diligence/app/controllers/api/v1/property_reports_controller.rb

Auth/authz isn't scattered before_action hooks any more — every controller declares an ordered guard chain via the guard macro (#353). Api::BaseController sets the canonical chain; PropertyReportsController opts in to the tenancy-membership guard for index/show but not for the pre-tenancy create.

# packs/core/app/controllers/api/base_controller.rb
class Api::BaseController < ApplicationController
  skip_forgery_protection
  include Api::Pipeline

  guard Api::Guards::Authenticated
  guard Api::Guards::CsrfDefence,     only: :state_changing
  guard Api::Guards::AccountState,    only: :state_changing
  guard Api::Guards::ActiveRoleGrant
  guard Api::Guards::Authorize
  guard Api::Guards::Entitlement,     only: :state_changing
end

# packs/property_due_diligence/.../property_reports_controller.rb
class Api::V1::PropertyReportsController < Api::BaseController
  guard Api::Guards::ActiveTenancyMembership, except: :create
  ...
end
4 · Pipeline runs (one before-action)
app/packs/core/app/lib/api/pipeline.rb

The mixin installs one before_action :run_api_pipeline per controller. The engine resolves the per-action guard list (honouring only / except / if / replaces), runs guards in declared order, and short-circuits on the first deny. The resolved Principal the guards populate is exposed to Pundit via @pundit_user.

def run_api_pipeline
  context = Context.new(controller: self)
  @pundit_user = context.principal  # bound for ApplicationController#pundit_user

  self.class.resolved_pipeline_for(self).each do |declaration|
    result = declaration.guard_class.new.call(context)
    return render_pipeline_failure(declaration.guard_class, result) if result.deny?
  end
end
5 · Each guard returns Result.allow or Result.deny
app/packs/core/app/lib/api/guards/

Guards are stateless and uniform. They never render / raise / redirect themselves — they return a Result and let the failure renderer translate denies into JSON.

Authenticated · 401 if not signed in CsrfDefence · 403 without XHR or Bearer AccountState · 403 + elevated audit on non-active mutations ActiveRoleGrant · auto-selects or 403 with available_grants Authorize · marker; Pundit raises in the action body Entitlement · no-op today; 402 once #356 lands ActiveTenancyMembership · auto-selects or 403 with available_memberships
# packs/core/app/lib/api/guards/authenticated.rb
module Api::Guards
  class Authenticated < Base
    def call(context)
      return Result.allow if context.principal.authenticated?

      Result.deny(reason: :unauthenticated, http_status: 401)
    end
  end
end
6 · Action body — Pundit, AR, async work
app/packs/property_due_diligence/app/controllers/api/v1/property_reports_controller.rb

The pipeline has run; the controller method is plain Rails. authorize consults PropertyReportPolicy (which delegates to Tenancy::PolicyHelpers for owner / active-membership / withdrawn-ledger reach). report.save fires Audited callbacks. Anything that could time out (per-source aggregator calls) runs off the request path via SolidQueue.

def create
  report = PropertyReport.new(create_params.merge(account: current_account, status: :pending))
  authorize report          # Pundit; raises NotAuthorizedError on deny

  if report.save             # Audited writes the create audit in the same txn
    PropertyReportAggregationJob.perform_later(report.id)
    render json: PropertyReportSerializer.new(report).serialize, status: :accepted
  else
    render json: { errors: report.errors }, status: :unprocessable_content
  end
end
7 · Serializer → JSON response
app/packs/property_due_diligence/app/serializers/property_report_serializer.rb

Alba shapes the response. Timestamps are explicitly iso8601-formatted so the rswag schema check stays green. The FE-side type for PropertyReport comes from generated.ts, regenerated from swagger.yaml and CI-gated against drift.

# 202 Accepted
{
  "id": 42,
  "address_query": "M14 5JT",
  "status": "pending",
  "payload": {},
  "created_at": "2026-05-11T10:00:00Z",
  "tenancy_id": null
}
Deny path · pipeline-level deny
app/packs/core/app/lib/api/pipeline/failure_renderer.rb · logger.rb

When a guard denies, the pipeline aborts the chain, hands the Result to a single FailureRenderer, and emits one structured log line. One envelope shape across every deny path — clients key UI off error.code and route off error.http_status.

# 403 Forbidden — e.g. AccountState denied a mutation
{
  "error": {
    "code": "account_not_active",
    "http_status": 403,
    "metadata": { "state": "closed" }
  }
}

# Log line (one per deny):
# guard=AccountState controller=Api::V1::PropertyReportsController
# action=create reason=account_not_active principal_id=42 http=403
Deny path · Pundit raises in the action body
app/packs/core/app/lib/api/pipeline.rb

Authorize's real work happens inside the action via Pundit's authorize / policy_scope. When Pundit raises NotAuthorizedError, the pipeline's rescue_from maps it to the same JSON envelope — so the FE doesn't need a second error shape for "the policy said no" vs "a guard said no."

included do
  before_action :run_api_pipeline
  rescue_from Pundit::NotAuthorizedError, with: :render_pundit_failure
end

def render_pundit_failure
  result = Api::Guards::Result.deny(reason: :not_authorized, http_status: 403)
  render_pipeline_failure(Api::Guards::Authorize, result)
end
Cross-cutting · Pundit principal

Since #355, pundit_user returns an Api::Pipeline::Principal triple (account, active_grant, active_tenancy_membership) rather than a bare Account. Policies access user.account, user.active_grant, user.active_tenancy_membership. Tenancy::PolicyHelpers narrows the visible-records scope to the active membership's tenancy plus owner-reach + ledger-pinned records.

Cross-cutting · audit-as-integrity

Every meaningful AR mutation writes an audits row in the same transaction. Denied mutations on non-active accounts also write an elevated audit row (action = attempted_mutation_while_non_active, comment = elevated) so admin tooling can filter on a single tag. Architecture v1.2 evidence-grade — "adjudicator-grade, not forensic."

Critical gaps — ranked

G1

Error-envelope schema drift (OpenAPI ↔ live)

High

OpenAPI ProblemDetails = {error: string, errors: {[k]: string[]}} — the old head :forbidden + AR-errors shape. Live endpoints since #353 return {error: {code, http_status, metadata}} with codes unauthenticated, csrf_defence, not_authorized, account_not_active, role_grant_required, tenancy_required, role_grant_not_found, tenancy_membership_not_found.

FE consequence: ApiError.body stays as unknown because the contract doesn't describe a usable shape. Every screen that needs to branch on a deny code (Dashboard's tenancy-required → switcher; Auth's account_not_active → state message; Move-In's entitlement_required → manage-account) can't do so off generated types.

G2

Active-tuple invisible to the FE

High

AccountSerializer returns {id, email, state, status} only. The Principal triple (account, active_grant, active_tenancy_membership) exists server-side but GET /me doesn't surface it. Switch endpoints (POST /me/active_role_grant, POST /me/active_tenancy_membership) return the same trimmed Account on success.

FE consequence: the Dashboard's "scope to active tenancy" requirement (screen 2) has no API affordance. The tenancy-switcher popover has nothing to display. Fix: expand Account (or add a Principal schema) to include active_role_grant, active_tenancy_membership, plus the selectable lists.

G3

41 / 47 OpenAPI paths have no live Rails route

High

All 30+ /tenancies/{tenancy_id}/* resources (evidence, deposits, repairs, notices, contacts, household_members, inventories, calendar_entries, checklist_tasks, document_generations, rent_ledger_entries, handover_workflows, share_artefacts, evidence vault, evidence search/exports), all /billing/* (subscription, checkout, portal), /me/entitlement, and the bare resource endpoints (/notices/{id}, /repairs/{id}, /deposits/{id}, etc.).

FE consequence: wiring against the contract is type-safe but every fetch will 404 until controllers + DB tables land. Pack-per-screen is the carve-out unit (see Phase 2/3 below).

G4

DB schema gap: ~67 ERD entities not migrated

High

Contracts reference entities with no create_table in db/schema.rb. Includes evidence_items, documents, notices, repair_issues, deposits, rent_payments, inventories, inspection_visits, meter_readings, tenancy_archives, share_artefacts, subscriptions, notifications, and every reference-data table.

FE consequence: even if a controller existed, the AR model wouldn't load. Each pack carve-out is one migration + AR model + controller + policy.

G5

Entitlement guard is still a no-op

Medium

Api::Guards::Entitlement in the pipeline allows everything (#353 stub). Account has no entitlement column; no Subscription table. OpenAPI declares /me/entitlement returning {state: paid|read_only|none}, but no controller, no schema, no real guard.

FE consequence: the "manage-account-when-cancelled" reader-app constraint can't be enforced from the API. Mutations all succeed regardless of subscription state. Blocks #356 and the Billing screen. Brand-promise constraint is currently aspirational, not enforced.

G6

Withdrawal-fork ledger has no UX surface

Medium

TenancyMembership::PerResourceShareGrant ledger and Tenancy::PolicyHelpers read-narrowing landed in #373. Switching to a withdrawn membership via POST /me/active_tenancy_membership succeeds, then subsequent reads return only ledger-pinned records — but the FE doesn't know it's in read-only mode.

FE consequence: users hitting "Add evidence" on a withdrawn-membership view will get owner-only-mutation 403s with no UI affordance. Suppress the affordance based on active_tenancy_membership.withdrawal_status (depends on G2) or land a deny-code-aware UI banner.

G7

Joint-tenancy consent has architecture but no implementation

Medium

docs/03-decide/joint-tenancy-consent-model.md pinned eight aggregate actions with rules (simple_majority / unanimous / lead_with_co_member_notification). TenancyAggregateAction / TenancyAggregateActionVote tables don't exist. Screen-arch mentions a "pending aggregate actions" section in the tenancy-switcher.

FE consequence: every joint-tenancy screen with a "close tenancy" / "archive" / "generate shared bundle" CTA needs aggregate-action plumbing. Per the ADR's consequences section: lands when the first aggregate-action UI surface needs it. Not blocking solo-tenancy or read-only flows.

G8

Pre-tenancy vs in-tenancy boundary isn't surfaced

Medium

PropertyReportsController#create is except: :create on ActiveTenancyMembership (Verify-before-let). MeController#show is exempt. The Dashboard renders differently per tenancy lifecycle stage (pre / live / post). The boundary lives in controller declarations — no per-route metadata the FE can introspect.

FE consequence: FE either hard-codes which screens need an active tenancy or calls them speculatively and handles tenancy_required 403. Latter is cleaner but depends on G1 (error envelope).

G9

Switch endpoints return Account, not the new state

Medium

Both set_active_role_grant and ActiveTenancyMembershipsController#create render AccountSerializer.new(current_account).serialize. The response doesn't carry the active-tuple.

FE consequence: even after a successful switch, the FE has no signed confirmation of which tenancy/grant is now active without a second roundtrip — and that second roundtrip also wouldn't surface the active tuple per G2. Fix in the same change as G2.

G10

No FE-side caching plan for the 11 unseen screens

Low

Dexie mirrors only PropertyReport. The data ?? cached pattern and isFromCache flag need to extend across Tenancy, Evidence, Repair, Notice, etc. Each is its own Dexie table with its own write-through.

FE consequence: not a tension with the API — a tension with the screen build-out scale. Pattern-per-pack rather than blocker.

Naming & consistency

Account vs Userarchitecture v1.2 + PRD use both interchangeably; ERD / OpenAPI / FE all picked Account. Not blocking; one-line note in the next ADR would tidy it.
Session ambiguity — resolved in ERD §9.1 (#393): extend Rodauth's account_active_session_keys rather than carve a unified sessions table. OpenAPI has no Session schema, which is correct.
Notice lives in both core and occupancy_rightstwo different entities sharing a name (cross-aggregate shared-kernel vs lifecycle-pack specialised). OpenAPI has one NoticeRecord; needs clarification before either pack carves out.
Subscription vs EntitlementERD §11.6 has Subscription; OpenAPI has both BillingSubscription (upstream Stripe shape) and Entitlement (the tri-state). Probably fine; CHANGELOG note when #356 ships.
HouseholdMember vs TenancyMembershipOpenAPI's HouseholdMember (children, non-tenant adults) is distinct from TenancyMembership (named tenants). Naming is OK; ERD §11.4 doesn't list HouseholdMember — add before that controller carves.

Wiring sequence I'd recommend

Phase 0 — Unblock everything else

One small PR each
  1. Replace ProblemDetails OpenAPI schema with the real pipeline envelope (G1). Land a Principal schema and expand /me + both switch endpoints to return it (G2, G9). Single PR. Unlocks every screen that needs to handle deny codes or display the active tenancy.
  2. Verify the FE can pre-cache available_memberships on auth so the switcher popover renders without a separate roundtrip. Probably the same PR; depends on the shape we pick for Principal.

Phase 1 — Lift the three live screens onto the new envelope

Live API exists
  1. Auth (screen 1) — types are there; RegisterPage / SignInPage / HomePage shipped. Pick up account_not_active handling once Phase 0 is in.
  2. Property Report (screen 3) — live; lift the existing pages onto the new error envelope.
  3. Dashboard pre-tenancy variant (screen 2 partial) — currently HomePage.tsx = search + recent reports.

Phase 2 — Tenancy + Evidence carve-outs (unlock most screens)

One PR per resource
  1. Tenancy CRUD (/tenancies, /tenancies/:id) — schema exists, OpenAPI exists; just needs controller. Unlocks Dashboard live + Key Contacts (screen 12).
  2. EvidenceItem (/tenancies/:id/evidence) — needs evidence_items table + soft-delete + R2 wiring. Unlocks Move-In (4), Move-Out (6), Repairs in Occupancy (5), Documents (9), Comms (11) attachments.

Phase 3 — One pack carve-out per remaining screen

Depends on Phase 2
  1. Deposit + DepositDeduction + Dispute → screens 6 / 7.
  2. RepairIssue + RepairUpdate + RepairDeadline → screen 5.
  3. Notice + NoticeResponse → screens 5 / 6 (resolve the Notice naming clash first).
  4. Reference → screen 8 (post-deposit).
  5. CommunicationThread + CommunicationEntry → screen 11.
  6. LegalHold + RetentionRule + DSAR — not user-facing but unblocks deletion semantics across all of the above.

Phase 4 — Cross-cutting

Lands when needed
  1. Guards::Entitlement (#356) + Subscription schema + Billing screen.
  2. TenancyAggregateAction (joint-tenancy consent) when the first aggregate-action UI surface lands.
  3. CI conformance check (#358) once enough controllers exist for it to be meaningful.

Are we ready to start UI work?

Ready now

  • Auth (screen 1)
  • Property Report (screen 3)
  • Dashboard — pre-tenancy variant only

Blocked on Phase 0

  • Any screen that branches on deny codes (G1)
  • Any screen that displays the active tenancy (G2 / G9)
  • The tenancy switcher (G2)

Blocked on pack carve-out

  • All 10 remaining screens (G3 / G4)
  • Each is one PR: migration + AR model + controller + policy + FE wiring
  • Roughly one PR-day each at recent cadence

Sources: docs/artifacts/screens.html (screen architecture), docs/05-design/entity-relationship-model.md (ERD), app/swagger/v1/swagger.yaml (OpenAPI surface), app/config/routes.rb (live routes), app/db/schema.rb (schema), tenantmate-app/src/lib/api/ (FE client + types). Find sibling artefacts at the hub.