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 contextReadCreateChange contentChange status
Signed outDenyDenyDenyDeny
Researcher in Lab groupOwn group's recordsOwn groupOwn submitted record before reviewDeny
Coordinator in Lab groupOwn group's records and review queueDenyDenyOwn group's status only
Requester in Company teamOwn requestsOwn team, self as requesterOwn request while NewDeny
Approver in Company teamOwn team's requests and review queueDenyDenyOwn team's status only
Any role, different group or teamDenyDenyDenyDeny

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:

StateWhat the user should seeAvailable next action
Signed outPurpose statement and sign-in controlSign in
First sign-in, no recordsRole and scope in plain language; the one task this app supportsCreate a permitted item or open the permitted queue
Empty queue"No records are waiting for review"Return to all permitted records
No search matchThe query and "No matching records"Clear search
Access deniedGeneric permission message with no record factsReturn to permitted list or report a problem
Service failureHuman wording and a non-secret reference ID when availableCheck 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 fieldPurposeWho sets it
idStable record identifierService or database
created_by_user_idLinks creation to authenticated identityService or database
scope_id or team_idGroup or team boundaryDerived from membership
statusConstrained workflow statePermitted role through validated action
versionDetects stale updatesService or database
updated_atShows recency without resolving conflicts silentlyService 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:

AttemptExpected function resultExpected stored result
Requester creates valid requestcreated, new ID, version 1One New row in the Requester's active team
Requester sends four-character summaryinvalidNo row or change
Requester edits current New versionsaved, next versionOnly content fields change
Approver reviews current versionsaved, next versionOnly status changes
Approver submits stale version after Requester content savestale, current versionFirst save remains unchanged
Wrong role or wrong team calls a function with otherwise valid parametersdenied, no versionNo 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:

TestSessionAttemptPassing result
Signed outNoneOpen protected list or detailSign-in state; no record fields
Allowed readLower-privilege roleOpen permitted recordOnly permitted fields and scope
Role differenceLower-privilege roleDirectly attempt the protected actionGeneric denial; no protected data or change
Allowed role actionHigher-privilege rolePerform that action on a permitted recordAgreed result persists; version increments for an update
Forbidden fieldReviewer role in sampleAdd content to a status-only requestWhole call rejected; content and status unchanged
Wrong scopeEither roleRequest a known different-scope IDNo protected fields disclosed
Invalid inputCreator roleSubmit boundary and unknown valuesField error or safe rejection; no invalid row or update
No matchEither roleSearch for SYN-NOT-FOUNDClear no-match state; permitted list returns after clear
Concurrent updateTwo independent sessionsSubmit permitted changes from the same starting versionSecond save refused; first change preserved
Failure and feedbackEither roleUse an approved test error or unavailable-service simulationSafe 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

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.