Three things to fix before FE → BE wiring kicks off across more than the three live screens:
ProblemDetails schema doesn't match the live {error: {code, http_status, metadata}} failure envelope. FE can't branch on deny codes off generated types.GET /me returns the bare Account; the Principal triple (account, active_grant, active_tenancy_membership) shipped in #355 is invisible to the FE.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.
Canonical screen architecture v2. 3 planes (Workflow / Events / Information) + 4 cross-cutting surfaces.
73 operations across 66 component schemas. Contracts-first via rswag --dry-run; most are documented but not yet routed.
GET /me, POST /me/active_role_grant, POST /me/active_tenancy_membership, GET / GET-by-id / POST /property_reports. That's it.
5 Rodauth aux + accounts + audits + role_grants + properties + tenancies + tenancy_memberships + tenancy_parties + property_reports. rake docs:erd_lint green.
~70 still Architectural; awaiting pack carve-out. Each pack lands the migration, model, policy, controller in one PR.
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"}}' })
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
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
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
Guards are stateless and uniform. They never render / raise / redirect themselves — they return a Result and let the failure renderer translate denies into JSON.
# 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
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
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
}
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
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
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.
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."
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.
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.
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).
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.
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.
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.
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.
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).
Account, not the new stateBoth 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.
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.
Account vs User — architecture 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_rights — two 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 Entitlement — ERD §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 TenancyMembership — OpenAPI'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.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.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.RegisterPage / SignInPage / HomePage shipped. Pick up account_not_active handling once Phase 0 is in.HomePage.tsx = search + recent reports.Tenancy CRUD (/tenancies, /tenancies/:id) — schema exists, OpenAPI exists; just needs controller. Unlocks Dashboard live + Key Contacts (screen 12).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.Deposit + DepositDeduction + Dispute → screens 6 / 7.RepairIssue + RepairUpdate + RepairDeadline → screen 5.Notice + NoticeResponse → screens 5 / 6 (resolve the Notice naming clash first).Reference → screen 8 (post-deposit).CommunicationThread + CommunicationEntry → screen 11.LegalHold + RetentionRule + DSAR — not user-facing but unblocks deletion semantics across all of the above.Guards::Entitlement (#356) + Subscription schema + Billing screen.TenancyAggregateAction (joint-tenancy consent) when the first aggregate-action UI surface lands.
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.