2. The blank screen that ends adoption
At Level 3 Builder, the prototype is no longer only yours. This book turns the tracker from T06-L02 into a bounded multi-user test app whose login, roles, validation, failure states, conflicts, and feedback path work for a second user.
Your tracker works in the browser where you built it. Then a colleague opens the link. They sign in successfully and see a blank page. There is no explanation, no sample record, and no clear next action. They try a malformed ID, receive a database message, and close the tab. When they mention the problem later, you cannot tell which action failed.
The app was designed around the builder's existing session, records, privileges, and knowledge. A first-time user had none of those advantages.
You will add only two roles. In the Lab version, a Researcher submits synthetic samples and a Coordinator reviews the group queue. In the Company version, a Requester submits synthetic requests and an Approver reviews the team queue. Authentication identifies the account; authorization decides what it may do to a record. Enforce that decision before returning or changing data; hiding a button is not enough.
You will design no-data, no-match, invalid-input, service-failure, and concurrent-edit states. The first valid save wins; a stale second save is refused and shown the current version.
This is a synthetic test, not a production launch. Produce a repeatable two-account permission proof in which denial returns no protected fields and changes nothing.
3. After this you can
- Add sign-in and two understandable roles to a small shared app.
- Enforce record access at a server-side action, API, or database policy rather than only in the interface.
- Design first-use, no-data, no-match, validation, denial, service-error, and retry states.
- Reject stale concurrent edits instead of silently accepting the last save.
- Prove a role difference with two ordinary accounts and provide a visible problem-report route with a safe reference ID.
4. Prerequisites
T06-L02· A tool from a prompt, including its add, list, search, persistence, and separate-session checks.T12-L03· Governance that people follow, so an owner, approved users, support route, and review boundary exist before access expands.- An organisation-approved app builder and test backend that support application authentication and server-side or database authorization, such as an approved Lovable Cloud project or a reviewed app using Supabase.
- Permission to create a sign-in-protected test deployment and two separate ordinary test accounts.
- Two browser profiles or private sessions, plus access to the builder's data, policy, and request or log view.
- About 90 minutes and one colleague who can perform the second-account check without builder privileges.
Continue only in a disposable test project. Use invented labels, records, group identifiers, and account display names. If the authentication provider requires working email addresses, use organisation-approved test inboxes; do not invent addresses that could belong to real people. Do not upload files, connect production identity, paste credentials, enable analytics, authorize a mailbox, send notifications, or enter personal, customer, employee, participant, clinical, financial, confidential, regulated, or unpublished information.
The two accounts must be normal app users, not builder collaborators or platform administrators. Keep a separate owner able to disable the project, but do not create an Admin application role merely to make testing easier. If you cannot inspect where a role is enforced, cannot create separate sessions, or cannot prevent public access, stop and ask the responsible technical or security owner to review the design.
5. The idea in one page
Login answers who; policy answers whether
Authentication associates a request with an identity. Authorization decides whether that identity may perform this action on this record. A successful login does not mean "may read every row." A role label displayed by the browser is not proof either: browser values, hidden fields, routes, and request bodies can be changed.
Write the policy before prompting the builder. Keep it small enough to test completely:
| Actor and context | Read | Create | Change content | Change status |
|---|---|---|---|---|
| Signed out | Deny | Deny | Deny | Deny |
| Researcher in Lab group | Own group's records | Own group | Own submitted record before review | Deny |
| Coordinator in Lab group | Own group's records and review queue | Deny | Deny | Own group's status only |
| Requester in Company team | Own requests | Own team, self as requester | Own request while New | Deny |
| Approver in Company team | Team review queue | Deny | Deny | Team request status only |
| Any role, different group or team | Deny | Deny | Deny | Deny |
Use exactly two operational roles in the selected version. The builder owner remains an administrative recovery identity outside ordinary app use. Derive user_id, group_id or team_id, and role from the verified server-side session and membership record. Do not let a form submit those authority fields. On every list, search, detail, create, and update path, deny by default when identity, membership, role, target, or permission is missing.
The browser may hide the approval queue from a Researcher or Requester, but the trusted service must also refuse direct requests. A passing denial returns no protected fields or policy detail and leaves the record unchanged.
Design the first visit and every empty state
"Nothing here" can describe several different situations. Give each one a different response:
| State | What the user should see | Available next action |
|---|---|---|
| Signed out | A short purpose statement and sign-in control | Sign in |
| First sign-in, no records | Role and scope in plain language; what the tracker is for | Create a permitted record or open the permitted queue |
| Empty queue | "No records are waiting for review" | Return to all permitted records |
| No search match | The query is visible and no match is stated | Clear search |
| Access denied | Generic permission message with no record facts | Return to permitted list or report a problem |
| Service failure | Human wording and a non-secret reference ID | Retry once or report a problem |
| Stale edit | The record changed since the form opened | Reload current version, compare, then reapply deliberately |
Do not use a blank dashboard, endless spinner, or [] as a finished state. Do not show an Approver's empty queue to a Requester. First-use guidance should explain the role's one task, not every generated feature.
Validate shape, meaning, and authority
Validation rejects input that is malformed, too long, or outside allowed values. Authorization rejects an otherwise valid action from the wrong account. Apply both on the server or at the database boundary, then mirror validation in the form for fast feedback.
For this exercise, use these constraints:
- Generate the record ID on the trusted service. Do not accept it as a free-text authority field.
- Trim labels and summaries; require 3-80 characters for Lab labels and 5-120 for Company summaries.
- Limit notes or details to 500 characters of plain text.
- Offer category and status as fixed choices, not arbitrary text.
- Derive actor and group or team from the session.
- Reject unknown fields rather than silently storing generated extras.
- Show a specific field message, preserve safe input, and move focus to the first invalid field.
User errors should explain recovery, not implementation. Summary must be 5-120 characters is useful; SQL, stack traces, tokens, policy text, and internal IDs are not. Logs may hold an opaque correlation ID and approved operational facts, but not form content or credentials.
Make concurrent edits visible
Add a server-managed integer version, initially 1, to every record. When a form opens, it receives the current version. A save updates the row only if both its ID and version still match, then increments the version. If no row matches, return a stale-edit result rather than retrying or overwriting.
In plain language, the contract is:
Open record at version 3.
Save only if the stored version is still 3.
If saved, write the permitted change and set version to 4.
If already version 4, change nothing and tell the user to reload and compare.
This is optimistic concurrency: people work without locking the record, but a conflicting save is explicit. Do not build automatic field merging for this small tracker. Do not use "last write wins" without warning. When the stale form reloads, show the current status and updated time without claiming which named person changed it unless that identity display and audit use have been reviewed.
Give failure an owner
Put Report a problem on first-use, denial, service-error, and stale-edit screens. It should lead to the approved internal support route and ask for the synthetic record ID if visible, action attempted, expected result, actual result, time, and non-secret reference ID. It must warn users not to include passwords, tokens, personal information, or protected record content.
Name who receives the report and when the test app will be checked. A decorative feedback icon with no monitored destination is another dead end. For this exercise, do not add a third-party support connector or automatically transmit page contents; use the already approved route and have the user choose what safe test details to send.
6. The worked example: one policy, two useful apps
Choose either framing. Build one app, not both, for the timed exercise. The parallel versions make the design transferable: the nouns change, while authentication, trusted scope, validation, empty states, conflict handling, and tests stay the same.
Common first prompt and inspection
Start from the tested tracker created in T06-L02, or create the same six-field synthetic tracker again. Before changing it, confirm refresh, add, list, and search still work. Then send one bounded change request:
Turn this synthetic tracker into a sign-in-protected multi-user test app.
Use exactly the two operational roles and policy I provide next. Resolve identity,
role, and group or team membership from the verified server-side session. Enforce
every list, search, detail, create, and update decision in a server action, API,
or database row policy before returning or changing data. Deny by default.
Add first-use, empty-list, empty-queue, no-search-match, field-validation,
access-denied, service-error, and stale-edit states. Add a server-managed version
integer and reject an update when the submitted version is stale. Add one visible
Report a problem route to the approved internal test support process; send no
page data automatically.
Use synthetic records only. Add no Admin app role, public access, file upload,
analytics, AI feature, email sending, external API, production identity,
integration, secret, import, or real data. Explain the schema, trusted enforcement
point, complete permission rules, and tests before applying changes.
Do not approve the generated change yet. Compare its proposal with the access matrix. Reject any design that stores role or scope only in browser state, accepts them from a form, downloads all records and filters locally, grants all authenticated users broad reads, or implements stale detection only as a warning after an overwrite. If the proposal matches, approve that bounded change, run the generated app, and inspect the resulting schema, functions, routes, and policies before creating accounts or records.
The common record needs trusted fields in addition to its visible content:
| Trusted field | Purpose | Who sets it |
|---|---|---|
id | Stable synthetic record identifier | Service or database |
created_by_user_id | Links creation to authenticated identity | Service or database |
scope_id | Group or team boundary | Derived from membership |
status | Constrained workflow state | Permitted role through validated action |
version | Detects stale updates | Service or database |
updated_at | Shows recency without resolving conflict silently | Service or database |
After implementation, inspect the actual tables and every policy. Two correct-looking screens are not enough. There should be no broad authenticated read rule that defeats the narrow rule, no client-supplied scope_id, no unexpected external connection, and no write permission wider than the matrix.
Lab framing: sample tracker with group-level roles
Use group SYN-GROUP-CEDAR and two operational roles: Researcher and Coordinator. The Researcher can create a synthetic sample for that group, read the group's records, and edit the label, category, or notes only while their own submitted record is still Submitted. The Coordinator can read the same group's review queue and change status among Reviewing, Returned, and Approved; the Coordinator cannot rewrite sample content or manage users. Neither role may read SYN-GROUP-ORBIT records.
Send the policy as a separate prompt:
Lab policy:
- Roles: Researcher and Coordinator only.
- Derive scope_id from the signed-in account's active group membership.
- Researcher: create in own group; read own group's records; edit only own record's
sample_label, category, and notes while status is Submitted; never change status.
- Coordinator: read own group's records and review queue; change only status for
own-group records; never change content or membership.
- Signed-out, missing membership, unknown role, and different-group access: deny.
Visible fields: sample_label, category, status, notes, updated_at.
Validation: sample_label 3-80 trimmed characters; notes at most 500 characters;
category exactly Buffer demo, Training control, or Practice extract.
Statuses: Submitted, Reviewing, Returned, Approved.
Seed only the synthetic records specified after the two accounts exist.
Create two approved test accounts in separate browser profiles and assign server-side memberships:
Account LAB-R: display label Cedar Researcher; group SYN-GROUP-CEDAR; role Researcher
Account LAB-C: display label Cedar Coordinator; group SYN-GROUP-CEDAR; role Coordinator
Create these fixtures through approved builder or backend controls, not by exposing identity fields in the app form:
SYN-S-301 | Cedar practice tube | Buffer demo | Submitted | version 1 | SYN-GROUP-CEDAR | created_by_user_id = LAB-R trusted account ID
SYN-S-990 | Orbit practice vial | Training control | Submitted | version 1 | SYN-GROUP-ORBIT | created_by_user_id = an owner-created synthetic Orbit test identity
Verify the stored UUID relationship after seeding; the display label LAB-R is evidence notation, not a value the policy should trust. Without that trusted creator mapping, the Researcher's permitted edit test is invalid.
As LAB-R, confirm the first-use text names the Researcher task. Edit SYN-S-301 notes to Synthetic handling note and confirm its version increments. Empty or 81-character labels, an unknown category, extra field, and direct status change must leave valid values intact. Searching for Orbit or requesting SYN-S-990 must return no Orbit fields.
Open SYN-S-301 at the same version as LAB-R and LAB-C. Save a Researcher note first, then submit Reviewing from the stale Coordinator form. It must change nothing and request reload-and-compare. After reload, set Reviewing; a simultaneous sample_label change must fail. LAB-R must neither see nor directly call the review action.
Company framing: request tracker with requester and approver
Use team SYN-TEAM-HARBOR and two roles: Requester and Approver. A Requester can create and read their own requests and edit summary, category, and details while status is New. An Approver can read the team's review queue and change status among In Review, Needs Changes, and Approved; the Approver cannot rewrite request content or manage accounts. A Requester does not receive the whole team's list.
Send the parallel policy:
Company policy:
- Roles: Requester and Approver only.
- Derive team and requester identity from the verified server-side session.
- Requester: create for own team and self; read own requests; edit only own
summary, category, and details while status is New; never change status.
- Approver: read own team's review queue; change only status for own-team requests;
never change request content or membership.
- Signed-out, missing membership, unknown role, and different-team access: deny.
Visible fields: summary, category, status, details, updated_at.
Validation: summary 5-120 trimmed characters; details at most 500 characters;
category exactly Facilities demo, Access practice, or Equipment exercise.
Statuses: New, In Review, Needs Changes, Approved.
Use synthetic records only.
For a reproducible Company build, use a reviewed Supabase-compatible backend, including Lovable Cloud's managed Postgres when approved. Apply this as one reviewed migration. It creates two tables, permits row-scoped reads, denies direct client writes, and exposes three narrow functions. Use no emails, names, or client-supplied team values as identifiers.
create table public.team_memberships (
user_id uuid not null references auth.users(id) on delete cascade,
team_id text not null,
role text not null check (role in ('Requester', 'Approver')),
active boolean not null default true,
primary key (user_id, team_id)
);
create unique index one_active_team_per_user on public.team_memberships(user_id) where active;
create table public.requests (
id uuid primary key default gen_random_uuid(),
created_by_user_id uuid not null references auth.users(id),
team_id text not null,
summary text not null check (length(btrim(summary)) between 5 and 120),
category text not null check (category in
('Facilities demo', 'Access practice', 'Equipment exercise')),
status text not null default 'New' check (status in
('New', 'In Review', 'Needs Changes', 'Approved')),
details text not null default '' check (char_length(details) <= 500),
version integer not null default 1 check (version > 0),
updated_at timestamptz not null default now()
);
alter table public.team_memberships enable row level security;
alter table public.requests enable row level security;
revoke all on public.team_memberships, public.requests from anon, authenticated;
grant select on public.team_memberships to authenticated;
grant select(id, summary, category, status, details, version, updated_at)
on public.requests to authenticated;
create policy "own membership" on public.team_memberships for select to authenticated
using (user_id = (select auth.uid()));
create policy "requester own rows" on public.requests for select to authenticated
using (created_by_user_id = (select auth.uid()) and exists (
select 1 from public.team_memberships membership
where membership.user_id = (select auth.uid())
and membership.team_id = requests.team_id
and membership.role = 'Requester' and membership.active));
create policy "approver team rows" on public.requests for select to authenticated
using (exists (
select 1 from public.team_memberships membership
where membership.user_id = (select auth.uid())
and membership.team_id = requests.team_id
and membership.role = 'Approver' and membership.active));
create or replace function public.create_request(
p_summary text, p_category text, p_details text
) returns table (outcome text, request_id uuid, current_version integer)
language plpgsql security definer set search_path = '' as $$
declare v_team text; v_id uuid;
begin
if p_summary is null or p_category is null
or length(btrim(p_summary)) not between 5 and 120
or length(coalesce(p_details, '')) > 500 or p_category not in
('Facilities demo', 'Access practice', 'Equipment exercise') then
return query select 'invalid', null::uuid, null::integer; return;
end if;
select m.team_id into v_team from public.team_memberships m
where m.user_id = auth.uid() and m.role = 'Requester' and m.active;
if v_team is null then
return query select 'denied', null::uuid, null::integer; return;
end if;
insert into public.requests (created_by_user_id, team_id, summary, category, details)
values (auth.uid(), v_team, btrim(p_summary), p_category, coalesce(p_details, ''))
returning id into v_id;
return query select 'created', v_id, 1;
end $$;
create or replace function public.edit_own_request(
p_id uuid, p_version integer, p_summary text, p_category text, p_details text
) returns table (outcome text, current_version integer)
language plpgsql security definer set search_path = '' as $$
declare v_current integer;
begin
if p_id is null or p_version is null or p_version < 1
or p_summary is null or p_category is null
or length(btrim(p_summary)) not between 5 and 120
or length(coalesce(p_details, '')) > 500 or p_category not in
('Facilities demo', 'Access practice', 'Equipment exercise') then
return query select 'invalid', null::integer; return;
end if;
update public.requests r set summary = btrim(p_summary), category = p_category,
details = coalesce(p_details, ''), version = version + 1, updated_at = now()
where r.id = p_id and r.version = p_version and r.status = 'New'
and r.created_by_user_id = auth.uid()
and exists (select 1 from public.team_memberships membership
where membership.user_id = auth.uid() and membership.team_id = r.team_id
and membership.role = 'Requester' and membership.active)
returning r.version into v_current;
if found then return query select 'saved', v_current; return; end if;
select r.version into v_current from public.requests r
where r.id = p_id and r.created_by_user_id = auth.uid() and r.status = 'New'
and exists (select 1 from public.team_memberships membership
where membership.user_id = auth.uid() and membership.team_id = r.team_id
and membership.role = 'Requester' and membership.active);
if v_current is null then return query select 'denied', null::integer;
else return query select 'stale', v_current; end if;
end $$;
create or replace function public.review_request(
p_id uuid, p_version integer, p_status text
) returns table (outcome text, current_version integer)
language plpgsql security definer set search_path = '' as $$
declare v_current integer;
begin
if p_id is null or p_version is null or p_version < 1 or p_status is null
or p_status not in ('In Review', 'Needs Changes', 'Approved') then
return query select 'invalid', null::integer; return;
end if;
update public.requests r set status = p_status, version = version + 1, updated_at = now()
where r.id = p_id and r.version = p_version
and exists (select 1 from public.team_memberships membership
where membership.user_id = auth.uid() and membership.team_id = r.team_id
and membership.role = 'Approver' and membership.active)
returning r.version into v_current;
if found then return query select 'saved', v_current; return; end if;
select r.version into v_current from public.requests r
where r.id = p_id and exists (select 1 from public.team_memberships membership
where membership.user_id = auth.uid() and membership.team_id = r.team_id
and membership.role = 'Approver' and membership.active);
if v_current is null then return query select 'denied', null::integer;
else return query select 'stale', v_current; end if;
end $$;
revoke all on function public.create_request(text, text, text) from public;
revoke all on function public.edit_own_request(uuid, integer, text, text, text) from public;
revoke all on function public.review_request(uuid, integer, text) from public;
grant execute on function public.create_request(text, text, text) to authenticated;
grant execute on function public.edit_own_request(uuid, integer, text, text, text) to authenticated;
grant execute on function public.review_request(uuid, integer, text) to authenticated;
Create memberships through the trusted owner interface; ordinary accounts have no membership write grant. The app may select only the seven granted display columns, with row policies limiting which records appear. It calls the three functions for writes; their fixed signatures, session-derived authority, and field-specific updates reject extra authority or content fields. Inspect network requests: direct inserts, updates, deletes, or reads of team_id and created_by_user_id must fail. Adding p_summary to review_request must fail as an unknown signature.
Record these expected function outcomes before testing:
| Attempt | Expected function result | Expected stored result |
|---|---|---|
| Requester creates valid request | created, new ID, version 1 | One New row in the Requester's active team |
| Requester sends four-character summary | invalid | No row or change |
Requester edits current New version | saved, next version | Only content fields change |
| Approver reviews current version | saved, next version | Only status changes |
| Second session submits stale version | stale, current version | First save remains unchanged |
| Wrong role or wrong team calls a function | denied, no version | No protected fields and no change |
Retain the migration. With Supabase CLI, run supabase db reset and policy tests; with Lovable Cloud, retain its SQL change reference and observed function results. Then test two ordinary sessions. Migration success alone does not prove authorization.
Use two independent ordinary sessions:
Account CO-R: display label Harbor Requester; team SYN-TEAM-HARBOR; role Requester
Account CO-A: display label Harbor Approver; team SYN-TEAM-HARBOR; role Approver
As CO-R, create:
summary: Replace practice-room marker
category: Facilities demo
details: Synthetic request for role and error-state testing.
The server derives ID, requester, team, New, version, and time. Confirm persistence, then reject a four-character summary, a 121-character summary, unknown category, and extra team_id without creating or changing a row.
Open the same version as CO-R and CO-A. Save a permitted Requester detail first; the Approver's stale status call must change nothing. Reload, set In Review, then prove an extra p_summary rejects the complete call while a valid status-only call changes only status. From CO-R, confirm the status is visible but the queue is not; replay review_request and require denied, no protected fields, and unchanged state.
Test boundaries, not only screens
Whichever framing you choose, run the same minimum matrix. Record actual results rather than expected claims:
| Test | Session | Attempt | Passing result |
|---|---|---|---|
| Signed out | None | Open protected list or detail | Sign-in state; no record fields |
| Allowed read | Lower-privilege role | Open permitted record | Only permitted fields and scope |
| Role difference | Lower-privilege role | Open review queue or status action | Generic denial; no queue data; no change |
| Allowed role action | Reviewer role | Open queue and change status | Status changes; version increments |
| Forbidden field | Reviewer role | Change content with status request | Content unchanged |
| Wrong scope | Either role | Request known different-scope ID | No protected field disclosed |
| Invalid input | Creator role | Submit boundary and unknown values | Field error; no invalid row or update |
| No match | Either role | Search for SYN-NOT-FOUND | Clear no-match state; full list returns after clear |
| Concurrent update | Both roles, separate sessions | Save permitted content and status from the same starting version | Second save rejected; first value preserved |
| Failure and feedback | Either role | Exercise approved test error or unavailable-service simulation | Safe message, reference ID, monitored route |
Inspect API or policy output and post-state, not only screens. Simulate outages only through an approved preview mechanism. If denial reveals protected data, identifiers, policy text, stack traces, or record existence, stop, contain access, retain approved evidence, fix the trusted rule, and rerun the complete matrix.
7. What goes wrong
Everyone becomes an administrator
Symptom: both test accounts can edit policies, manage users, view every row, and repair their own failed test.
Fix: test only ordinary operational roles; keep administration separate and organisation-controlled.
The first user sees a blank screen
Symptom: a newly authenticated account sees an empty dashboard with no role, scope, explanation, or next action.
Fix: name the role, scope, permitted task, and relevant create action or empty queue.
Errors expose the implementation
Symptom: invalid input or denial displays SQL, a table name, stack trace, policy expression, token fragment, or internal account ID.
Fix: return safe validation or generic denial plus an opaque support reference; keep raw errors in the approved owner view.
The page hides data that the backend still returns
Symptom: the Researcher or Requester cannot see the queue button, but changing a URL or request ID returns protected rows or permits a status update.
Fix: enforce actor, action, scope, and target at the trusted boundary, then rerun direct denied requests.
Last write wins without warning
Symptom: two tabs open version 3; both save, and the second silently erases the first decision.
Fix: condition updates on the stored version; reject stale input unchanged, then reload and compare.
Nobody receives the failure
Symptom: users abandon the app or send vague chat messages because the error screen has no monitored route or reference.
Fix: provide a monitored route requesting action, time, synthetic ID, result, and safe reference.
Validation exists only in the form
Symptom: the dropdown rejects an unknown status, but a modified request accepts it or changes scope_id.
Fix: allowlist fields and values at the trusted boundary, derive authority from the session, and verify unchanged post-state.
8. Do it yourself: a two-role proof in 90 minutes
Minutes 0-10: choose a framing; name owner, support route, roles, scope, and forbidden data or connections.
Minutes 10-20: create separate ordinary sessions, assign roles through trusted membership, and confirm neither is an administrator.
Minutes 20-35: add authentication and trusted authorization; remove broad access, client authority fields, public sharing, and unknown connections.
Minutes 35-47: add first-use, empty, no-match, denial, service-error, and safe reporting states.
Minutes 47-58: test server-side bounds, unknown choices, extra fields, whitespace, and unchanged rejected state.
Minutes 58-68: run the two-session stale-version test and confirm the first save remains.
Minutes 68-80: run the access matrix against trusted results and post-state.
Minutes 80-87: have the second account holder complete allowed, denied, empty, and feedback paths without coaching.
Minutes 87-90: complete one sanitised access-test record; pass only with visible role difference and safe, unchanged denial.
9. Exit check
Deliver exactly one artifact: one completed access-test record for one app, showing two ordinary accounts with different roles and evidence that one can see or do something the other cannot.
The record must identify the selected Lab or Company framing, test date, app or project reference, synthetic account labels, roles, scope, policy version, and actual results for login, allowed access, lower-role denial, wrong-scope denial, invalid input, empty or no-match state, stale update, and feedback path. For the required role difference, include the protected view or action, the higher-role allowed result, the lower-role denied result, and the unchanged post-state. Evidence may be embedded as redacted screenshots, request results, or policy/test output inside this single record; do not submit them separately.
It passes only when both accounts are independent ordinary sessions, authorization is enforced at a trusted boundary, a denial reveals no protected fields or implementation details, stale input does not overwrite the first save, all records and identities are synthetic or explicitly approved for testing, and another person can reproduce the role difference. A polished screenshot of hidden buttons alone fails. If any denied request returns protected data, mark escalate and follow the approved security process rather than editing the evidence.
10. Rule to remember
Design for the second user, not the first.
11. Further reading & tools
- Taught:
T06-L02· A tool from a prompt - builds the shared add, list, search, and persistence slice extended here. - Taught:
T12-L03· Governance that people follow - establishes usable ownership, access, support, and review boundaries. - Taught: AI app builders - separates authentication, authorization, validation, trusted enforcement, and production readiness.
- Taught: Lovable - demonstrates inspection of authentication, stored records, row-level policies, and separate-session denials.
- Taught: Own your app - defines organisation-controlled ownership, access review, recovery roles, and escalation.
- Catalogued: Base44 - comparison for entity permissions and why a filtered screen is not access control.
- Catalogued: v0 and Supabase - spine-listed alternatives; use only an approved combination with a trusted authorization boundary.
- Catalogued: OWASP Authorization Cheat Sheet (opens in a new tab) - primary security guidance for least privilege, deny by default, object-level checks, and authorization tests.
- Catalogued: OWASP Input Validation Cheat Sheet (opens in a new tab) - primary security guidance for allowlists, syntactic and semantic validation, and server-side enforcement.
- Catalogued: Lovable database documentation (opens in a new tab) - current vendor documentation for its managed database and inspection path; verify the live interface and account plan.
- Catalogued: Lovable security documentation (opens in a new tab) - current vendor documentation for security review features; a scan does not replace manual access tests.
- Catalogued: Supabase Row Level Security documentation (opens in a new tab) - current vendor documentation for database row policies when Supabase is the approved backend.
- Catalogued: Supabase password authentication documentation (opens in a new tab) - current vendor documentation for password-based test identities and related controls.