1. The link is not the handoff
Your colleague does not want an app. They want to finish the task that brought them to it: find the latest equipment note, submit a request, review a draft, or see what needs attention before the next meeting. Yesterday you showed them a polished preview. Today they open the link without you beside them.
They sign in and see a blank page. Is nothing waiting, is the search wrong, or have they lost access? They try again. A save fails, but the form disappears. Later, two people change the same item and the second save silently replaces the first. The app works in your browser; the work still depends on you.
This book closes that gap. Continue the small app you chose in T06-L02. Give one colleague a recurring job they can complete, a result they can trust within the agreed test boundary, and a way forward when something fails. Level 3 Builder means that someone can depend on what you built, not merely admire what you generated.
Keep the project small. One useful workflow, two operational roles, and a bounded group are enough. The Lab sample tracker and Company request tracker below are optional synthetic examples, not a requirement to abandon your own project. A shared reading queue or equipment-note app can teach the same ideas with different fields and actions.
Open your existing project in its approved app builder. The builder editor is where you ask for and inspect changes; the running app is what your colleague uses; the backend controls show stored data and enforce who may use it. These are three different views. Success in the editor's preview does not prove success for an ordinary signed-in app user.
The boundary remains a restricted test deployment with synthetic or explicitly approved test material. A colleague may depend on it for that bounded task while an agreed fallback remains available. Connecting consequential real data and proving database restoration belong in T06-L04; operating a production service belongs in T06-L05.
2. After this you can
- Build one recurring colleague workflow in your chosen app, with a clear start, saved result, and next action.
- Add sign-in and two understandable roles, enforcing record access before data is returned or changed.
- 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.
- Hand over the working app with actual two-account evidence, a visible support route, and a non-destructive fallback.
3. Start with the work, then choose the boundary
Use the prototype from T06-L02, an organisation-approved builder and test backend, two independent ordinary app accounts, and a colleague who can try the second role without builder privileges. Reserve a focused working session; allow longer if policy inspection needs technical help. A timebox is not permission to skip a failed access check.
Before prompting, write one sentence in the project's existing notes:
When [recurring event] happens, [colleague] opens [screen], does [permitted action], and leaves [saved result] for [next person or next step]. If the app is unavailable, we use [agreed existing fallback].
For example: "Before the weekly equipment meeting, the coordinator opens the review queue, marks the synthetic practice requests reviewed, and leaves a current status for each requester." Your own project might instead let an editor approve a reading-list entry. The important dependency is what the recipient can do next, not whether the app has a dashboard.
Agree the two roles from that workflow. A creator and reviewer are often sufficient; a contributor and reader may be more appropriate. Do not invent approval powers just to imitate the example. Write down who receives problems and who can restrict access if the test becomes unsafe. T12-L03 develops that ownership agreement; here you put it into the app's actual behavior.
Work in an approved disposable test project or isolated test copy. Do not replace the existing working version before the new path passes. Use invented labels, records, groups, and display names. If sign-in needs working email addresses, use approved test inboxes, not invented addresses that might belong to real people. Do not add uploads, production identity, analytics, mailbox access, automatic notifications, or external integrations for this exercise. Keep personal, customer, employee, participant, clinical, financial, confidential, regulated, and unpublished material out of the test.
The two accounts must be ordinary app users, not builder collaborators or platform administrators. Keep a separate owner able to restrict the project, but do not create an Admin application role to make testing easier. If the builder cannot enforce your rules outside the browser, stop at the existing prototype and ask the responsible technical owner to review the missing capability. Do not change platforms merely because this book includes SQL.
Find the controls you will actually use
For an approved Lovable Cloud project, open the project toolbar's More menu, then Cloud. Its Database area exposes tables and security policies; Users manages app users and sign-in; SQL editor runs database queries and commands; Logs provides backend activity. These are owner tools, not screens to share with the colleague. Enabling Cloud can create backend resources: confirm that connection is already approved before asking the builder for authentication.
In an approved Supabase project, use the project dashboard's SQL Editor for a reviewed one-off database change, or retain the change as a migration in the project's existing migration workflow. The optional SQL example later is for a fresh disposable schema, not a command to paste over existing tables. Supabase and Lovable Cloud are alternative backend arrangements here; you do not need a separate Supabase account merely because Lovable's backend uses its open-source foundation.
In another approved builder, locate its equivalent user, data, policy, and request/log views before proceeding. Record their real names in your handoff. If you cannot inspect the trusted enforcement point, an attractive preview is not enough evidence to expand access.
4. The design that survives a second user
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.
Think of the workflow as a round trip:
Colleague opens a permitted screen
-> app sends the action with the signed-in session
-> trusted service checks identity, role, scope, fields and version
-> stored state changes only if the complete action is allowed
-> app shows the saved result, a recoverable error, or a safe denial
-> the next person opens the current permitted state
Write the policy before prompting the builder. For your own project, replace the nouns and actions below rather than inheriting unnecessary permissions. Here are the two optional tracker policies side by side:
| 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 | Own team's requests and review queue | Deny | Deny | Own team's status only |
| Any role, different group or team | Deny | Deny | Deny | Deny |
Use two operational roles in the selected example, not all four. The 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 review controls from a Researcher or Requester, but the trusted service must also refuse their status-changing requests. A menu is navigation, not an access boundary. In the Lab policy both roles can read group records; the protected difference is the status action, not secrecy of those records. In the Company policy a Requester sees only their own rows while an Approver sees the team's rows. If your app has a distinct reviewer-only queue endpoint, protect that endpoint too. Do not claim that the sample's row policies forbid every request made from a route called "queue."
A passing denial returns no protected fields and leaves the record unchanged. For an inaccessible target, avoid telling the caller whether the record exists. For an authorized read filtered by row policy, an empty result can be the correct outcome; the application should translate that result into a safe, understandable state rather than leak details while explaining it.
Design the first visit and every empty state
An empty screen asks the user to diagnose your app. Replace it with a short explanation and a permitted next action:
| State | What the user should see | Available next action |
|---|---|---|
| Signed out | Purpose statement and sign-in control | Sign in |
| First sign-in, no records | Role and scope in plain language; the one task this app supports | Create a permitted item or open the permitted queue |
| Empty queue | "No records are waiting for review" | Return to all permitted records |
| No search match | The query and "No matching records" | 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 when available | Check save status, safely retry, or report a problem |
| Stale edit | "This record changed since you opened it" | Reload current version, compare, then reapply deliberately |
Do not use a blank dashboard, endless spinner, or [] as a finished state. Do not send a Requester to an Approver's empty queue. First-use guidance should explain the role's task, not every generated feature. A useful page might say: "Your practice requests appear here. Create a request to send it for review. Open an existing request to see its current status."
Use visible labels for fields and controls; do not rely on placeholder text or color alone. Keep the workflow usable by keyboard. Move focus to the first invalid field when submission fails and associate its message with that field. Make save, error, and conflict notices available to assistive technology without unexpectedly moving the user elsewhere. Include these behaviors in the builder request and inspect the result, not just its appearance.
Validate shape, meaning, and authority
Validation rejects malformed, too-long, or disallowed input. Authorization rejects an otherwise valid action from the wrong account. A well-formed approval from a Requester is still forbidden. Apply both at the server or database boundary, then mirror validation in the form for fast feedback.
Choose bounds appropriate to your own project. The optional tracker examples use these constraints:
- Generate the record ID on the trusted service, not in 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 and preserve safe input when correcting it remains permitted.
Summary must be 5-120 characters helps someone recover. SQL, stack traces, tokens, and policy expressions do not. Translate transport and database errors into safe app messages as well as handling the normal function outcomes. Owner logs may hold approved operational facts and an opaque correlation ID, but not credentials or copied form content. A raw backend response is not suitable text for a user-facing error panel.
Make concurrent edits visible
Add a server-managed integer version, initially 1, to each editable record. When a form opens, it receives the current version. A save updates the row only if both its ID and version still match and the action remains authorized, then increments the version. This comparison and write must be one atomic database operation, not a browser check followed by an unconditional save.
Open record at version 3.
Save only if the stored version is still 3 and this action is still permitted.
If saved, write the permitted change and set version to 4.
If already version 4, do not overwrite it; reload and compare if still authorized.
This is optimistic concurrency: people work without holding a lock while they think, but a conflicting save becomes explicit. "First valid save wins" applies to competing changes from the same starting version; it does not mean the record can never be edited again. After reload, an authorized user can make a fresh deliberate change.
Do not build automatic field merging for a small app unless the chosen task genuinely needs it. When a 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. If the first save removes the second user's editing permission, the second attempt may correctly receive denied rather than stale. Do not disclose a current version to someone who is no longer entitled to it.
Separate a failed save from an unknown save
An explicit validation rejection means nothing was saved. A connection loss after clicking Save is different: the service may have committed the change before the reply was lost. Do not label that result "Not saved" unless you know it.
For a failed read, offer a bounded retry. For an uncertain write, say "We could not confirm this save. Check the current record before trying again." Reopen the permitted list or detail view and compare the result. The sample's version check prevents replaying a stale update, but it does not make request creation duplicate-safe. Do not automatically resend a create request. If duplicate creation would have a serious consequence, keep that action out of the pilot until the technical owner has provided an appropriate duplicate-prevention design.
Give failure an owner
Put Report a problem on first-use, denial, service-error, and stale-edit screens. Use an existing approved internal route, not a new support integration. Ask for the synthetic record ID if visible, action attempted, expected result, actual result, time, and non-secret reference ID if one was received. Warn against including passwords, tokens, personal information, or protected record content. Do not automatically transmit page contents.
Agree who receives the report and when this bounded test will be checked. If the app is unavailable, the colleague should know which existing channel or working version to use and who will reconcile the pending work. That fallback is part of the workflow, not a footnote after the app fails.
5. Build one useful shared path
Make the change in the builder, not in the colleague's session
In the builder editor, open the existing test project. First run the current app and confirm that its useful action and persistence still work after refresh. Keep the prior working version or approved recovery reference. In the project's existing notes, name the new action, the two roles, and the expected saved result.
Then send one bounded change request in the builder's chat. Replace the bracketed text with your own project decisions; do not paste placeholder roles into a live policy:
Extend this existing test app; do not replace its useful workflow or data model.
Recurring task: [colleague's trigger, action, saved result and next step].
Roles and access policy: [the two roles, allowed actions, fields and scope].
First explain the proposed screens, schema changes, trusted enforcement point,
permission rules and checks. Do not apply changes until I approve the proposal.
Resolve identity, role and scope from the verified server-side session and
membership. Enforce list, search, detail, create and update permissions before
returning or changing data. Deny by default; do not filter unauthorized rows
only in the browser.
Add first-use, empty-list, empty-queue where relevant, no-match, validation,
access-denied, service-error and stale-edit states. Use labeled controls,
keyboard access, associated field errors and accessible status messages.
For editable records, use an atomic version-checked save and reject stale
updates without overwriting. Do not automatically retry creates or unknown saves.
Report a problem must lead to [approved internal route], with no automatic
page-data transfer. Keep the existing fallback available.
Use synthetic or approved test material only. Add no Admin app role, public
record access, upload, analytics, AI feature, email sending, external API,
production identity, integration, import or real data.
Do not approve the generated change yet. Compare its proposal with your access matrix. Reject a 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 detects a stale edit only after overwriting the row. A request in chat is not itself a technical permission barrier; inspect the builder's actual change and approval controls.
When the proposal matches, approve that bounded change. In the owner backend view, inspect the actual schema, functions, routes, and policies. Then open the running app in two separate browser profiles. Sign in as ordinary users there, never as the owner used to inspect the database.
The sample trackers need trusted fields alongside visible content:
| Trusted field | Purpose | Who sets it |
|---|---|---|
id | Stable record identifier | Service or database |
created_by_user_id | Links creation to authenticated identity | Service or database |
scope_id or team_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 conflicts silently | Service or database |
Inspect every policy and effective grant. Two correct-looking screens are not enough. There should be no broad authenticated read rule defeating a narrow rule, no client-supplied authority field, no unexpected connection, and no write permission wider than the matrix. In PostgreSQL, table grants control permitted operations while row policies control which rows those operations can reach. You need both.
Optional Lab route: sample tracker
Use group SYN-GROUP-CEDAR and roles Researcher and Coordinator. The Researcher creates synthetic samples, reads group records, and edits their own content while status is Submitted. The Coordinator reviews group records and changes status, but cannot rewrite sample content or manage users. Neither role may read SYN-GROUP-ORBIT records.
In the builder chat, supply this policy after the common change request:
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 specified synthetic records after the test accounts exist.
Through the trusted owner controls, assign these memberships to two approved test accounts:
LAB-R: display label Cedar Researcher; group SYN-GROUP-CEDAR; role Researcher
LAB-C: display label Cedar Coordinator; group SYN-GROUP-CEDAR; role Coordinator
Using approved backend fixture controls, create the following records. The labels SYN-S-301 and SYN-S-990 are fixture references: if the backend generates UUIDs, record the actual UUID beside each label and use that UUID in requests.
SYN-S-301 | Cedar practice tube | Buffer demo | Submitted | version 1
scope: SYN-GROUP-CEDAR; creator: LAB-R's trusted account ID
SYN-S-990 | Orbit practice vial | Training control | Submitted | version 1
scope: SYN-GROUP-ORBIT; creator: owner-created synthetic Orbit test identity
Do not expose identity fields in the app form to make fixture creation easier. Verify the stored creator relationship; LAB-R is evidence notation, not a value the policy should trust.
In the Researcher's app session, confirm the first-use text names the actual task. Edit the Cedar record's notes to Synthetic handling note and confirm persistence and an incremented version. Empty or 81-character labels, an unknown category, an extra field, and a direct status change must leave stored values intact. Search for Orbit and request the known Orbit ID directly: neither may disclose Orbit fields.
Open the Cedar record at the same version in both sessions. Save a Researcher note first, then submit Reviewing from the stale Coordinator form. That second save must change nothing and request reload-and-compare. After reload, the Coordinator can set Reviewing. A request that also attempts to change sample_label must fail as a whole, not partially apply status. The Researcher can see the permitted record status but cannot change it, including by a direct request.
Optional Company route: request tracker
Use team SYN-TEAM-HARBOR and roles Requester and Approver. A Requester creates and reads their own requests and edits their content while status is New. An Approver reads the team's requests and changes status, but cannot rewrite content or manage accounts. A Requester does not receive the whole team's list.
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 requests and 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.
The two sample policies are deliberately limited: Returned and Needs Changes do not give the creator permission to edit again. They flag a result for discussion through the agreed route; there is no resubmission loop. Nor do the examples enforce an irreversible approval sequence. If your chosen task needs resubmission or final approvals, specify its allowed transitions and test them before anyone relies on those meanings. Do not let friendly status labels promise a workflow the backend does not implement.
Optional implementation: understand the Company SQL
Keep this implementation if a reviewed Supabase-compatible backend is already your approved route. Otherwise use the same policy and checks in your existing builder; SQL is not a separate assignment.
The migration below is retained from the published example. Read it as three layers. team_memberships supplies trusted identity-to-team relationships. requests holds constrained content and a version. Read grants and row policies restrict what ordinary users can retrieve, while three narrow functions handle writes. The browser cannot submit a team or choose an arbitrary writable column.
The functions use security definer, so their explicit checks are critical: do not assume the caller's row policies alone constrain an elevated function. Each function derives membership from auth.uid(), and the two update functions include permission and version conditions in the write itself. The empty search path and qualified object names make object resolution explicit. Keep these protections when asking a builder to adapt the example.
Have the technical owner review this against the target backend and its existing grants. In the test project's SQL Editor, apply it only to a fresh disposable schema with these object names unused, and retain the SQL change reference in the existing project record. Do not rerun it as a reset: its create table statements are not an idempotent migration. For an existing app, have the owner prepare the smallest reviewed migration instead of deleting its tables.
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. Inspect effective function permissions too: revoking execution from public does not remove a separate grant a project may already give to anon. Ask the technical owner to narrow any additional grants to the intended roles before testing. Never put an owner or service-role credential into the app to work around a denied operation.
The app selects the seven granted request columns explicitly: id, summary, category, status, details, version, and updated_at. A generated select * also asks for restricted columns and is not the intended read. Row policies then limit which records appear. Writes call the three functions; their fixed signatures and field-specific updates do not accept extra authority or content fields. Direct inserts, updates, deletes, and reads of team_id or created_by_user_id must fail for ordinary app sessions.
Record these expected, not yet observed, 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 |
| Approver submits stale version after Requester content save | stale, current version | First save remains unchanged |
| Wrong role or wrong team calls a function with otherwise valid parameters | denied, no version | No protected fields and no change |
Unknown function signatures or malformed typed parameters may fail before the function returns an outcome. The app still needs to translate those transport errors safely. A valid status call with an extra p_summary must reject the complete call; do not silently discard the unwanted field and approve the status change.
Retain the migration and the actual policy/function test results in your existing project record. If your approved Supabase project already has database policy tests, use its existing narrow test workflow. Do not install a toolchain just to complete this book, and do not reset a shared or live database. Migration success alone is not authorization proof.
Run the Company workflow in ordinary app sessions
Assign CO-R to Requester and CO-A to Approver, both in SYN-TEAM-HARBOR, using the trusted owner controls. These are evidence labels for actual approved test accounts, not login credentials or trusted user IDs.
In CO-R's running app, 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. Note the returned ID, refresh, and open the record again. In CO-A's app, open that same record. The saved request should now support the colleague's next action, not merely appear in the owner's database view.
Reject a four-character summary, a 121-character summary, an unknown category, and an extra team_id without creating or changing a row. Open the same current version in both sessions. Save a Requester detail first, then submit the Approver's stale status call: it must change nothing. Reload as Approver, set In Review, and confirm that only status, version, and time changed. From the Requester session, call review_request with otherwise valid parameters; require denied, no protected fields, and unchanged state.
For the Company read boundary, have the owner seed an additional synthetic request by another trusted test identity in Harbor and one in another team. Record the actual IDs. The Approver can read the other Harbor request; the Requester cannot. Neither can read the other-team request. These fixture identities are not extra operational roles, and they must not require real personal records.
Inspect requests as well as screens
In the ordinary app browser, open Developer Tools, select Network, and perform one allowed action. Select that request and inspect its URL, method, request fields, and response. This connects the visible button to the actual trusted endpoint. In the owner backend view, inspect the affected synthetic row before and after the action.
Use the project's existing policy test facility or an approved request client to make the corresponding denied call with the lower-role session, not the owner's credential. Never share a copied request containing cookies or authorization headers. If you do not have an approved way to vary and replay a request, ask the technical owner to run that boundary check and retain its sanitized result; hiding a button is not a substitute.
Run this matrix for your chosen app, adapting names but keeping the risks:
| 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 | Directly attempt the protected action | Generic denial; no protected data or change |
| Allowed role action | Higher-privilege role | Perform that action on a permitted record | Agreed result persists; version increments for an update |
| Forbidden field | Reviewer role in sample | Add content to a status-only request | Whole call rejected; content and status unchanged |
| Wrong scope | Either role | Request a known different-scope ID | No protected fields disclosed |
| Invalid input | Creator role | Submit boundary and unknown values | Field error or safe rejection; no invalid row or update |
| No match | Either role | Search for SYN-NOT-FOUND | Clear no-match state; permitted list returns after clear |
| Concurrent update | Two independent sessions | Submit permitted changes from the same starting version | Second save refused; first change preserved |
| Failure and feedback | Either role | Use an approved test error or unavailable-service simulation | Safe recovery message and working, monitored support route |
If your roles do not both edit records, run the concurrency check with two independently opened sessions of an account permitted to edit. Keep the two-role permission check separate. Do not add a reviewer power solely to manufacture a conflict demonstration.
Inspect responses and stored post-state, not only screens. Simulate outages only through an approved preview mechanism. If a denial reveals protected data or existence, or a forbidden action changes state, restrict access, preserve only approved evidence, fix the trusted rule, and repeat the affected boundary checks plus the normal workflow. Do not edit the evidence to make the test pass.
6. What goes wrong
Everyone becomes an administrator
Symptom: both test users can manage accounts, change policies, view every row, and repair their own failed test.
Fix: separate owner administration from ordinary operational roles. Test with the privileges the colleague will actually have.
The first user sees a blank screen
Symptom: a newly signed-in account sees an empty dashboard with no explanation or next action.
Fix: name the role's task and permitted scope. Distinguish "nothing created," "nothing waiting," and "no search match." Ask the colleague what they would do next without prompting them.
Errors expose the implementation
Symptom: invalid input or denial displays SQL, policy expressions, stack traces, token fragments, or internal account identifiers.
Fix: map errors to safe field guidance or generic denial plus a non-secret reference when available. Keep raw diagnostics in the approved owner view, not the error panel.
The page hides data the backend still returns
Symptom: the queue button is absent, but a changed request ID returns another user's protected row or permits a status update.
Fix: enforce actor, action, scope, target, and fields at the trusted boundary. Recheck direct denied requests. Do not loosen a row policy to repair a broken screen.
Last write wins without warning
Symptom: two tabs open version 3, both save, and the second silently erases the first decision.
Fix: compare the version inside the write. Reject the second stale action unchanged, then let an authorized user reload and compare. Also test the case where the first change removes editing permission.
A retry creates the same work twice
Symptom: a connection error invites another Save, and two requests later appear.
Fix: treat the first result as unknown until checked. Do not automatically retry creates. Reopen the permitted list and involve the owner if the result remains ambiguous.
Nobody receives the failure
Symptom: a feedback icon goes nowhere, or a report reaches someone who did not agree to support the test.
Fix: use the agreed monitored route and minimal safe details. State the fallback where the colleague encounters the problem, not only in the builder's notes.
Validation exists only in the form
Symptom: a dropdown prevents an unknown value, but a modified request accepts it or changes the team.
Fix: allowlist fields and values at the trusted boundary, derive authority from the session, and verify unchanged post-state after rejection.
Recovery means deleting the evidence
Symptom: the proposed fix is "reset the database" even though colleagues have pending work in it.
Fix: pause the affected action, preserve permitted state, and use the agreed fallback. Return to a known working app version only if it remains compatible with the current schema and data; reverting code does not undo a migration. Destructive restore drills belong in an explicitly disposable target, not this handoff.
7. Do it yourself: make the next visit work
Use the following sequence as a roughly 90-minute build-and-handoff session if the backend and accounts already exist. Otherwise separate preparation from the session. Continue your chosen project; the optional fixtures are there only when you need safe practice material.
Choose the dependency, about 10 minutes. Name the colleague, recurring trigger, permitted action, saved result, next step, owner, and fallback. Open the current working app and confirm the useful slice still works. Preserve the prior version or recovery reference before changes.
Build the boundary, about 25 minutes. Write the two-role matrix, ask the builder for the bounded proposal, inspect it, then apply the agreed change in the test project. Check the trusted implementation and effective permissions. Create or assign the approved ordinary accounts through owner controls, then open independent app sessions.
Make failure usable, about 20 minutes. Add first-use and empty states, field guidance, safe denial, service-error wording, stale-edit recovery, and the monitored problem route. Check keyboard operation. Verify invalid values and extra fields leave storage unchanged. Separate uncertain saves from confirmed rejection.
Prove the boundary, about 20 minutes. Run the relevant access matrix and concurrent-edit check. Record actual request outcomes and post-state in the existing project notes. Fix a failing rule before proceeding; a screen recording of the happy path cannot compensate for a leaked row.
Hand over, about 15 minutes. Give the colleague the restricted app link and a short operating note. Do not open the editor for them. Ask them to complete the agreed task, find its saved result after refresh, interpret an empty or denied state, and locate the support route. Observe without coaching, then fix the specific confusion they encounter. If the colleague or review is unavailable, mark the handoff pending rather than inventing a successful trial.
Agree the next real opportunity to use the bounded app again. That return visit is where recurring value becomes visible: can the colleague find current work and continue without asking which tab or version you meant? Record what actually happens when it happens; do not report planned adoption as observed use.
8. Exit check: the app is the artifact
Deliver one working shared app for the agreed task, accompanied by its short operating note and reproducible checks. A separate spreadsheet, tracker, CSV, or elaborate evidence bundle is not required. Keep supporting evidence beside the project rather than making it the learner's replacement project.
The operating note should tell the recipient:
- The restricted app link, purpose, test boundary, and role-specific first action.
- What saved result means the task succeeded and how to find it again.
- Who may use which actions and what data must not be entered.
- How to handle a stale edit, an uncertain save, or an unavailable service without overwriting or duplicating work.
- Who receives problems, the agreed response window for this test, and the existing fallback.
- Which working version or change reference was checked, what the colleague actually tried, and what remains pending.
Attach or link concise sanitized evidence for the two ordinary accounts, their roles and scope, the allowed and denied version of one protected action, wrong-scope access, invalid input, empty/no-match state, conflict handling, and feedback. Record actual dates and outcomes. Include the unchanged stored result for a rejected write; for a denied read, show that protected fields were not returned. Redacted request results or existing policy-test output are useful; an image of a hidden button alone fails.
The build passes when the colleague can complete the agreed task without builder privileges, recover through the designed states, and find the saved result on return; trusted checks enforce the stated permissions and stale input cannot overwrite the first save. State whether the first handoff was observed and whether the later recurring visit is still pending. This is a bounded working app, not a claim of production readiness or an invitation to introduce sensitive data.
If a denied request returns protected data, stop expanding access and follow the approved security process. If a check or colleague trial has not happened, keep that part explicitly unfinished. Honest limits are more useful than a polished success story the next user cannot reproduce.
9. Rule to remember
Design for the second user, including the day you are not beside them.
10. Further reading & tools
- Continue: T06-L02: A tool from a prompt builds the chosen prototype and checks its useful shared slice; this book adds dependable multi-user behavior.
- Next boundary: T06-L04: Real data behind it develops an organisation-owned database/API and a measured restore in a disposable target. T06-L05: Owning and operating your app takes up ongoing operation.
- Related: T12-L03: Governance that people follow supplies the ownership and support agreement; the app must enact it, not merely link to it.
- Worked tool route: Lovable Cloud documentation (opens in a new tab) describes the project toolbar's More > Cloud route and its Database, Users, SQL editor, and Logs areas. This interface guidance was checked for this revision; account controls and permissions still need local inspection.
- Worked backend route: Supabase Row Level Security documentation (opens in a new tab) explains grants, policies, and database checks. Its visible grants/policies and SQL Editor guidance was freshly checked; the sample migration was not executed for this revision.
- Inherited reference: OWASP Authorization Cheat Sheet (opens in a new tab) covers least privilege, deny by default, object-level checks, and authorization testing.
- Inherited reference: OWASP Input Validation Cheat Sheet (opens in a new tab) covers allowlists, syntactic and semantic validation, and server-side enforcement.
- Inherited reference: Lovable database documentation (opens in a new tab) and security documentation (opens in a new tab) provide vendor detail; a scan does not replace ordinary-account access checks.
- Inherited reference: Supabase password authentication documentation (opens in a new tab) covers password-based identities and related controls. Use only the sign-in approach approved for the test.
- Catalogued alternatives: Tools index lists app-building options including Base44 and v0. This book does not teach their current permission interfaces or require a platform switch.
How this book was made
This is an Astra editorial revision of the complete published T06-L03 manuscript. It retains the source's detailed permission model, synthetic examples, SQL implementation, and boundary tests while making the learner's own app and colleague handoff the main path. The source record does not name an original author. Official Lovable Cloud and Supabase documentation informed the bounded interface and access-control clarification; no app run, screenshot, SQL execution, independent reader trial, or classroom result is claimed here. The practical lesson is the same as the app's: preserve what already works, make the next person's action explicit, and distinguish a prepared handoff from observed use.