Approvals: when a change needs a second decision
How a submitted product waits as a pending approval request, and how the owner's decision turns it into live inventory or closes it
The Alerts tab on Folake's phone shows a banner: 1 new product awaiting approval. A team member has submitted a product for the Surulere pharmacy's inventory, and it waits for her decision. Folake opens the review screen, reads the submitted name, SKU, unit label, opening stock, and low-stock threshold, and taps Approve. One transaction writes the product into live inventory, attributed to the team member who submitted it. The product enters exactly as submitted; the decision is the only thing Folake adds.
Approval requests (approval_requests) #
An approval request records one submitted business action and holds it for a decision by someone other than its requester, the member who submitted it. The paired add-product capabilities choose the path at creation time: a member holding add_products_directly writes a live product, and a member holding submit_products_for_approval creates an approval request instead. Product creation is the one approval-backed action at launch.
A request carries:
action_type: the action under decision;inventory.product.createis the single registered typestatus:pending,approved, orrejectedsubject_labelandsubject_code: the product name and the optional SKU, for list rows- the submitted product exactly as typed: name, SKU, unit label, opening stock, and low-stock threshold
- the requester, with the moment of submission
- the decision fields, written once at decision time: who decided, when, and an optional decision note
A request is immutable after submission; the decision fields are its only later write. While it is pending, the submitted product exists inside the request alone: the product row, the threshold, the movement ledger, the balance, and the alerts are written only when approval succeeds. The submission checks the SKU against live products, so a collision fails at submission time with the same error direct creation gives; a clean check leaves the SKU unreserved, and another product can still claim it while the request waits. A request waits indefinitely, and it leaves pending only through a decision. The request list holds submissions alone; a direct create writes zero request rows.
The stored snapshot
- Beyond the product fields, a product-create request snapshots the business's Default Location, the requester summary, the requester's add-product mode at submission time, and the submission's idempotency key.
subject_labelandsubject_codecarry the product name and SKU into list rows, so lists render with zero parsing of the stored payload.- Detail responses return typed submitted product data; clients render fields as sent instead of interpreting raw stored JSON.
Submitting a product for approval #
Submission reuses product creation whole: the same form, the same five fields, the same POST /products command. The server reads the member's add-product mode and produces one of two outcomes: direct mode writes the live rows and answers 201, and approval mode stores the request and answers 202 with {"status": "pending_approval"} and the request snapshot. The inventory doc carries the form and the direct outcome; submission also emits an approval_request.submitted audit event attributed to the requester.
The offline queue treats both outcomes as one operation. Product creation is one of the two writes the app queues offline, and the idempotency record stores whichever response the server produced, so a replayed submission returns the same stored answer and lands once. On the phone, a submission confirms as Sent for approval, and the waiting request then sits on the Sync tab as its own card; the tab-bar badge counts actionable rows alone, because a waiting request asks nothing further of the requester.
Folake opens Ngozi's member card and turns on Add products; the row defaults to the submit-for-approval mode, and the save stores submit_products_for_approval beside Ngozi's view_inventory and create_stock_movements grants. The next week a supplier introduces a new pain reliever, and Ngozi fills the Add product form: Paracetamol 500mg, unit label "pack", opening stock 40, low-stock threshold 12. Save confirms Sent for approval, and the request waits for Folake.
Submission on the wire
- The
202envelope is a convention:{"status": "pending_approval", "approval_request": {...}}, whereapproval_requestis the request detail shape. Every later approval-backed command answers the same envelope, so clients handle pending submissions uniformly. - The
create_productidempotency record stores the202outcome exactly as it stores a201: a replay with the same payload returns the stored envelope, and a replay with a different payload answers409 idempotency_conflict. - The app persists the envelope's request id beside the queued write from the moment of submission, so every waiting row carries its join handle for later reconciliation.
Who sees a request #
Visibility follows the request's two parties. A member's visibility covers the member's own submissions, pending and decided. A decider, any member holding decide_product_approvals, reads every request in the business; the Owner holds that capability implicitly and is its only holder at launch. The rule is separate from inventory access: view_inventory opens products and stock levels, and requests stay visible to their requester and to deciders alone.
For the owner, pending requests arrive as attention. The Alerts tab shows the banner "N new products awaiting approval", computed per viewer, so only a decider ever sees a count. The banner opens the Pending product reviews list: one row per pending request with the product name, the submitting member's name, and the submission date. The alerts feed carries the same attention as a derived item, computed from the pending requests themselves on every read, with zero stored alert row.
On Home, Recent Activity lists a submission for its requester alone: the event needs view_inventory, and it appears only on the requester's own preview, phrased as the viewer's own action. Decision events sit behind view_audit_history, the Owner-only audit grant; the audit doc carries the full scoping model.
Reads on the wire
GET /approval-requestsfilters byaction_type,status, andrequested_by=me.statusdefaults topending;decidedis a virtual filter returningapprovedandrejectedrequests in one page. Pages carry 1 to 100 items, default 50, newest first behind an opaque cursor.GET /approval-requests/{approval_request_id}returns the full request. A request id from another business answers404 approval_request_not_found, so existence stays private across businesses.- A decided list item carries the decision summary and a slim result: the created product id for an approved request, the terminal status for a rejected one. The full
product_resultpayload stays on the detail route. - The pending reviews screen loads
status=pendingwithlimit=100and shows a truncation note when more pages exist.
Deciding a request #
A pending request accepts two commands, approve and reject, and it accepts them only from a member other than its requester. Deciding runs on the current membership, and a request outlives its requester's access: it stays decidable when the requester's membership is later disabled, and the stored attribution still names the requester.
Approve re-checks the live constraints the submission could only preview: the SKU must still be unique among live products, and the snapshotted location must still be valid. A failed re-check answers a typed conflict and leaves the request pending, so the owner can decide it again after the fix. A passing approve materializes the product, meaning it writes the live rows: the product, the Default Location threshold, the opening movement when the snapshot's opening stock is above zero, the balance, and the low-stock alert transition, all in one transaction through the same write path as direct creation.
Attribution follows the requester. The created product and the opening movement record the requester as actor, and the opening movement stores the submission's idempotency key as its client_operation_id, so the request, the requester's queued write, and the movement ledger agree on one operation.
Reject closes the request and stores an optional decision note of up to 500 characters. Live inventory stays exactly as it was; rejection writes the decision fields alone.
Decisions replay by terminal state. Repeating the same decision returns the stored result, and the opposite command on a decided request answers a conflict. When a decision arrives second, the app shows "This request was already decided." and reloads the detail, which then shows who decided and when in place of the buttons.
The banner on Folake's Alerts tab reads 1 new product awaiting approval. She opens the review screen: Paracetamol 500mg under an Awaiting approval pill, Ngozi named as submitter, and the submitted fields in the details card. Approve writes the product with its opening movement of 40 packs attributed to Ngozi, and the screen returns to the pending list with a Product approved confirmation. On Ngozi's next product-list refresh, Paracetamol 500mg is live inventory.
Decisions on the wire
POST /approval-requests/{approval_request_id}/approveanswers200with the decided request andproduct_result, the same shape as the direct create response.POST /approval-requests/{approval_request_id}/rejecttakes an optionaldecision_noteand answers200with the decided request.- Decision commands run without idempotency keys; replay safety comes from the terminal state. Repeating a decision returns the stored
result_payload; the opposite decision answers409 approval_request_terminal_state, and a requester deciding the requester's own request answers409 approval_request_self_decision. - A failed re-check answers
409 sku_already_exists,409 approval_location_invalid, or409 approval_request_client_operation_conflictwhen the submission's idempotency key collides with an existing movement'sclient_operation_id. Each leaves the requestpending. - Approval emits
approval_request.approvedattributed to the decider plusproduct.createdwith metadata linking the request, the requester, and the decider. Rejection emitsapproval_request.rejectedwith the note's presence recorded in metadata.
After the decision #
A decided request stays readable indefinitely through the list and the detail routes, so a requester whose queued submission synced weeks ago still finds every outcome. An approved request keeps the created product's id as its canonical reference and the full created-product payload on its detail route. A rejected request keeps the terminal status and the decision note beside the untouched snapshot, so the requester reads exactly what the owner rejected. Resubmitting after a rejection is a fresh create under a new idempotency key.
Requester reconciliation
- A client holding pending local writes reads
requested_by=me&status=decided, joins items to its local rows by request id, the same id the202envelope returned, and takesresult.product_idas the canonical reference for an approved create. - Retention backs the pattern: decided requests stay readable with zero visibility window to race against.
The retired approval policies (approval_policies) #
Early builds keyed approval to roles: an approval_policies table mapped each stored role to an add-product mode. The grant model re-keyed the decision, and the table is retired and dropped. The paired capabilities now choose the mode per member, decide_product_approvals gates deciding, and business onboarding seeds zero policy rows.
History survives the re-key as immutable strings. A request submitted before it keeps its role-keyed policy snapshot, with labels such as Stock Clerk preserved as plain text, and a legacy pending request stays decidable by the owner exactly like a new one. New submissions record the deciding capability inside the policy snapshot.
Policy snapshot versions
- A request submitted before the re-key stores a role-keyed
schema_version: 1snapshot. A new submission storesschema_version: 2: an emptyapprover_roleslist, with the deciding capability recorded inside the snapshot. requester_roleis a plain string label of the requester's collapsed role at submission time, carrying zero enum semantics.requester_role_moderecords the add-product mode, one ofnot_allowed,allowed_directly, orrequires_approval.- Stored payloads and snapshots are immutable approval facts: a malformed historical payload answers
409 approval_request_payload_invalid, and a malformed stored result answers409 approval_request_result_invalid, in place of a lenient read.
Launch boundaries #
The workflow is built so later approval-backed actions arrive as additions. The boundaries today:
- The action registry holds
inventory.product.createalone; an unsupported action type fails validation and writes zero request rows. - Deciding stays with the Owner:
decide_product_approvalsis one of the 6 Owner-only capabilities, and a later slice opens it to member grants with a toggle alone. - A request changes only through approve and reject; it waits indefinitely, and decided requests stay readable for good.
- The app's Reject dialog sends the bare decision today; the optional decision note travels through the API alone.
- Deciding needs a connection. Submission rides the product-create offline queue, and the requester's waiting card clears when decision reconciliation ships in a later milestone; the approved product itself already reaches the product list.