# Inside the deal room: turning conversation into rental terms

> How Rental OS separates private discussion, versioned proposals, bilateral sign-off and closure evidence—and how one missing state-machine edge broke every signed counter-proposal until it was fixed.

Published: 2026-09-23  
Updated: 2026-09-23  
Work period: July to August 2026  
Product: [Rental OS](https://adityadalmia.com/projects/rental-os/)  
Canonical HTML: https://adityadalmia.com/blog/rental-os-inside-the-deal-room/

A tenant likes an apartment after a visit. The owner is interested, too. That sounds close to a completed rental, but it leaves several different kinds of uncertainty.

Imagine a hypothetical negotiation for $1,200 a month. The tenant can move next week; the owner expects the following month. A **security deposit**, money held against obligations such as damage, might be $2,400 on top of the monthly rent. **Brokerage**, the fee for arranging the rental, might be $600. Someone still has to establish which amounts were proposed, which were accepted, and which payments or documents actually exist.

A chat transcript contains parts of those answers. It also contains questions, tentative offers, corrections and messages one party should never see. Turning its last message into a “deal complete” flag would discard most of the useful context.

[Rental OS](https://adityadalmia.com/projects/rental-os/) models these as separate records: conversations, proposals, signatures, operational requirements and closure. A **closure** is the platform's record that a rental reached its completion stage; recording one for the hypothetical $1,200 rental does not move any money. The source is [open under the MIT licence](https://github.com/AdityaVikramDalmia/rental-platform-os), so every function named below can be read in full.

My role is product and application engineering. Next.js and React provide the web framework, Convex provides the database and reactive backend functions, and WorkOS provides authentication. Preparation, verification and the September fixes used AI assistance.

The first article, [One person, many roles](https://adityadalmia.com/blog/rental-os-one-person-many-roles/), covers identity across the product. This one asks the narrower question that comes after identity: what evidence should let a particular rental move forward? It also tells the story of a bug in exactly that machinery, which this article used to report as an open defect.

## Three rooms, one negotiation

A negotiation owns up to three chat channels: operations with the tenant (`OPS_TENANT`), operations with the owner (`OPS_OWNER`), and a `COMBINED` room. Operations is the team coordinating the rental between the parties.

Separate channels give private conversations an explicit boundary. A tenant can discuss a budget constraint with operations without that message entering the owner's history. The combined room is for when both sides are ready to talk together.

The backend creates rooms progressively. `negotiations.initiateNegotiationInternal` creates the tenant room. `openOwnerRoom` requires a linked owner. `openCombinedRoom` requires both private rooms to exist. Repeated calls return the existing channel rather than creating duplicates.

The alternative, one channel with an audience field on every message, would make every message query, unread counter, preview, export and notification responsible for interpreting that field correctly. Separate channels make the boundary a property of the room. They still need an authorization check at every entry point.

That check lives in `chatChannels.requireChatParticipant`. It resolves the caller's **party role** from the records: tenant if the caller is the inquiry's tenant, owner if the caller is linked to the listing's owner record. Room visibility is then computed from that role. Backoffice reads and posts fall back to explicit `chat.view` or `chat.send` permissions.

The party-role version is new. The pre-release authorization sweep found that room visibility had been keyed on `active_persona`, the switchable “which portal am I using” field. A person who was the tenant on an inquiry and also held an owner membership could switch persona and see the operations-owner room on that inquiry. The fix derives roles only from inquiry and listing links. A person who is both tenant and owner on the same inquiry now sees only rooms visible to both roles: fail closed. Twelve tests in `convex/dealRoomAuthz.test.ts` cover it; seven of them fail against the pre-fix code.

One inconsistency remains. Proposal signing, below, identifies the signer using the negotiation's own tenant and owner links plus the account's legacy primary type. A multi-membership account whose primary type is something else is refused a signature it arguably should be able to make. That path fails closed rather than open, but the two resolvers should converge.

![Backoffice negotiation detail showing tenant and owner participants, three room tabs, synthetic conversation and the operational checklist.](https://adityadalmia.com/_astro/negotiation-rooms.BP_xoCEh_ZxpmMD.webp)

*Local synthetic fixture, captured 2026-09-22. The administrator can inspect the tenant, owner and combined rooms; the checklist is a separate panel. “Auto-verified” labels mark derived checks. Grey crosses still mean incomplete.*

![Owner portal refuses a direct link to the tenant-only room.](https://adityadalmia.com/_astro/owner-room-boundary.Cqq0l_nF_2hrLo8.webp)

*Local synthetic fixture, captured 2026-09-22: the owner who could open the owner room could not open the tenant-only room by direct link. One account and channel pair, not an exhaustive authorization test; the regression suite covers the general rule.*

## A proposal is a version someone can accept

Conversation can stay informal. Acceptance needs a stable target.

An operations user with `negotiations.manage` creates a structured proposal in `negotiation_terms_proposals`: rent, deposit, lock-in and notice periods, move-in date, maintenance, escalation, furnishing terms, brokerage on each side, token advance and special conditions. Money fields are integer paise, the smallest unit of the Indian rupee, checked by `assertPositiveIntegerPaise` for rent and `assertNonNegativeIntegerPaise` for fees. The equivalent for the hypothetical $1,200 would be 120,000 cents: no floating-point money.

Each proposal carries a version number, and the negotiation holds an `active_proposal_id` pointer. Drafts are editable. `share` requires the draft to be the active proposal and every destination room to exist and belong to this negotiation. After sharing, the proposal is `SHARED` and no longer editable through `edit`.

This closes a familiar stale-page problem. Suppose the tenant has version two open while operations shares version three. The tenant's sign request names version two. `signTerms` compares it with the active pointer and rejects it with “Proposal is no longer active — it has been superseded.” instead of treating it as acceptance of newer terms.

`signTerms` checks, in order: a rate limit, that the proposal is `SHARED`, that the caller is the negotiation's linked tenant or owner, that the proposal is still active, that it was shared to the signer's room or the combined room, and that this user has not already signed. It then inserts a row into `negotiation_terms_signatures` with the signer's role, version and agreement text. When both a tenant and an owner signature exist, it marks the proposal `BOTH_AGREED`, locks it and advances the negotiation to `TERMS_AGREED`.

That record is stronger than a chat message saying “sounds good”: it names a version, signer, role, text and time. It is still an application sign-off, not an external electronic-signature service or a legally executed lease.

## The negotiation is a state machine

The proposal has four statuses. The negotiation around it has twelve, and `lib/negotiation.ts` declares which transitions between them are legal:

| From | Allowed next statuses (excerpt) |
| --- | --- |
| `ACTIVE` | `TERMS_PROPOSED`, `COUNTER_PROPOSED` |
| `TERMS_PROPOSED` | `COUNTER_PROPOSED`, `TERMS_AGREED`, `STALLED` |
| `COUNTER_PROPOSED` | `TERMS_PROPOSED`, `TERMS_AGREED`, `STALLED` |
| `TERMS_AGREED` | `TOKEN_COLLECTED`, `TERMS_PROPOSED`, `COUNTER_PROPOSED` |
| `STALLED` | `TERMS_PROPOSED`, `COUNTER_PROPOSED` |
| `READY_FOR_CLOSURE` | `DOCUMENTATION_IN_PROGRESS`, `CLOSED` |

Most statuses can also move to `FAILED` or `EXPIRED`; `CLOSED`, `FAILED` and `EXPIRED` are terminal. The status-changing mutations, including `share`, `signTerms`, `markStalled`, `markFailed` and `markExpired`, call `validateNegotiationTransition(from, to)` before patching the record.

![Admin negotiations list with one filter chip per negotiation status, from Initiated through Expired, and rows for synthetic tenants and owners.](https://adityadalmia.com/_astro/admin-negotiation-states.DqDHl3Al_Z1xOyLV.webp)

*Hosted demo, synthetic seed data, captured 2026-09-23. The filter chips are the twelve negotiation statuses. Seed records are dated early August, which is why every active negotiation is flagged stale; the rounds and days-to-close figures are seed-derived, not measurements.*

`share` chooses which proposal status the negotiation enters:

```ts
// convex/negotiationProposals.ts — share()
const nextNegotiationStatus = hasPreviouslySharedProposal
  ? NEGOTIATION_STATUS.COUNTER_PROPOSED
  : NEGOTIATION_STATUS.TERMS_PROPOSED;
```

The first shared proposal puts the negotiation in `TERMS_PROPOSED`. Every revision after that, whoever asked for it, puts it in `COUNTER_PROPOSED`. Revisions are also how a stuck negotiation becomes visible: each proposal counts as a round, and a negotiation with more proposals than the configured `negotiation_max_rounds` is flagged for attention.

## The bug: every signed revision failed at the second signature

Earlier versions of this article reported a defect found during a local synthetic walkthrough on September 22. An administrator created and shared version 3 of a proposal. The negotiation entered `COUNTER_PROPOSED`. The tenant signed successfully. The owner's Confirm & Sign then failed with:

```text
Invalid negotiation status transition from COUNTER_PROPOSED to TERMS_AGREED
```

![Tenant view of proposal version three with a recorded tenant signature and confirmation timestamp.](https://adityadalmia.com/_astro/tenant-proposal-signed.BEa6LmWR_Z1ue8o8.webp)

*Before the fix. Local synthetic fixture, captured 2026-09-22: the tenant signed the newly shared v3. The proposal stays Shared while the owner's signature is missing. No payment or AI request was made.*

![Owner sees the same shared proposal and tenant signature, with the owner sign action still available.](https://adityadalmia.com/_astro/owner-proposal-pending.DDjy-TsY_Z2jDqFM.webp)

*Before the fix. The owner's view after the second signature failed: the tenant's signature persisted, the owner's did not, and the proposal was never locked.*

### Root cause

Three facts combined:

1. `share` moves every revision into `COUNTER_PROPOSED`.
2. The transition table allowed `TERMS_PROPOSED → TERMS_AGREED` but had no `COUNTER_PROPOSED → TERMS_AGREED` edge.
3. `signTerms` only consulted the transition table when the *second* signature arrived, because that is when it tries to advance the negotiation.

So the first signature on any revision succeeded, and the second always threw. Nothing about the owner was special. Whichever party signed second hit the same error. The only proposals that could ever reach agreement were first proposals accepted without revision, which is also the path a quick manual test is most likely to try.

The failure left no half-written state, and that is Convex's transaction model doing its job. A Convex mutation runs as one transaction: if the handler throws, none of its writes commit. The second signer's `negotiation_terms_signatures` insert happened *before* the throw, and it vanished with the rolled-back transaction. The first signer's row came from an earlier, successful mutation, so it stayed. That is exactly the state in the two screenshots: one signature, an unlocked proposal, a negotiation still in `COUNTER_PROPOSED`.

Why did it survive into a working demo? The proposal module had no test file. The task that built it recorded the tests as deferred, verifying with typecheck and build instead. The demo seed also inserted agreed-state negotiations directly, which made the dashboards look healthy without ever running `signTerms` twice.

### The fix

Two commits, merged as `ab2c2bd`. The first (`ea52c2a`) adds the missing edge:

```ts
// lib/negotiation.ts
[NEGOTIATION_STATUS.COUNTER_PROPOSED]: [
  NEGOTIATION_STATUS.TERMS_PROPOSED,
  // share() puts every revision after the first into COUNTER_PROPOSED; both
  // signatures on that current shared version must be able to agree it.
  NEGOTIATION_STATUS.TERMS_AGREED,
  NEGOTIATION_STATUS.STALLED,
  NEGOTIATION_STATUS.FAILED,
  NEGOTIATION_STATUS.EXPIRED,
],
```

The second (`fc78a2c`) fixes the deeper design flaw: checking reachability at the last write instead of the first. `STALLED` has no edge to `TERMS_AGREED`, by design, since a stalled negotiation has to be revived through a new proposal first. Before the fix, a party could still sign a shared proposal in a stalled negotiation. The signature was recorded, and the counterpart's signature was then guaranteed to fail. Now `signTerms` asks the question before writing anything:

```ts
// convex/negotiationProposals.ts — signTerms(), after the signer, active-version
// and room checks and before the signature insert
if (!validateNegotiationTransition(negotiation.status, NEGOTIATION_STATUS.TERMS_AGREED)) {
  throw new Error(`Cannot sign terms while negotiation is ${negotiation.status}`);
}
```

The general rule I took from this: if an operation is the first half of a two-party commit, validate that the second half can succeed before accepting the first. Otherwise the system collects commitments it can never honour.

### The regression tests

`convex/negotiationProposals.test.ts` runs `signTerms` end to end against the real schema with `convex-test`, an in-memory Convex backend, with the rate-limiter and aggregate components registered. Each case builds a fresh negotiation with linked tenant and owner accounts and all three rooms. I ran the seven cases against the pre-fix revision and against `main`:

| Case | Pre-fix result |
| --- | --- |
| Initial proposal: `TERMS_PROPOSED → TERMS_AGREED` (control) | Passes |
| Counter-proposal, tenant signs first | Fails with the exact error from the walkthrough |
| Counter-proposal, owner signs first | Fails with the exact error |
| Third revision shared while already `COUNTER_PROPOSED` | Fails with the exact error |
| A signature on a superseded version is rejected and never counts toward the current one | Fails with the exact error once the counter-proposal completes |
| Signing while `STALLED` is refused and records nothing | Fails: the old code recorded the signature |
| A stalled negotiation revived by a shared revision reaches agreement | Fails with the exact error |

On `main` all seven pass. The full suite there runs 382 passing tests with one skipped. The control case matters: it shows the harness can complete a bilateral agreement, so the six failures come from the missing edge and the late check, not from a broken fixture.

After the fix was deployed, the same counter-proposal sequence completed on the hosted demo on September 23. Sharing version 1 put the negotiation in `TERMS_PROPOSED`, and sharing version 2 moved it to `COUNTER_PROPOSED`. The tenant signed. Then the owner signed, the step that used to fail, and both signatures were recorded: version 2 became `BOTH_AGREED` and the negotiation reached `TERMS_AGREED`.

![Tenant view of proposal version 2 marked Both Agreed, with the tenant and owner signatures recorded and the note Terms agreed.](https://adityadalmia.com/_astro/tenant-counter-proposal-agreed.Bug5DBHb_Z15W2rY.webp)

*After the fix: the counter-proposal both parties signed on the hosted demo. Synthetic seed data, captured September 23, 2026.*

What this does not cover: agreed terms still cannot be renegotiated through `supersede`, which rejects locked proposals, and `assertProposalMutationAllowed` blocks new proposals once terms are agreed and later. Renegotiation after bilateral acceptance is unfinished product work, not a bug in this path.

## AI prepares a checklist; people approve it

There is a second way to structure the conversation: an AI-assisted **deal checklist**, separate from the proposal record.

`actions/dealTermExtraction.extractTerms` reads delivered chat messages and requests structured output: terms, source-message references, confidence values and clarification flags. Its input is bounded to the most recent 100 messages and a 50 KiB text budget; `buildSelection` drops the oldest selected messages until the selection fits. An output-length failure triggers one smaller retry. Missing configuration, a refusal and an extraction failure each return an explicit outcome.

Those bounds have a product consequence. A long conversation can contain an earlier condition outside the analysed window, so a successful extraction does not mean every historical promise was found. Confidence is model output, not verification.

The useful output is therefore a draft. `dealChecklists.validateSourceMessageIdsForChannel` rejects source references that are missing or belong to another channel. Operations reviews and edits the draft. Tenant and owner then respond item by item: agree, disagree or comment.

Suppose the model extracts “move in next week” while the parties meant different dates. The disagreement becomes a disputed item. `dealChecklistApprovals.resolveDispute` writes the corrected value, resets both parties' approvals on that item, and deletes every existing checklist signature so nobody's earlier approval silently covers changed content.

`signOff` requires every item to be agreed by both parties. It stores a SHA-256 hash over a canonical form of the resolved items with the signer and time. Before approval, `regenerate` creates a new draft version and marks the old one superseded. The stored hash belongs to this checklist workflow; the proposal signature path computes a hash but does not persist it.

Extraction organizes evidence; approval stays a separate human action. Promoting extracted text straight to agreed terms would make the model responsible for resolving ambiguity that only the participants can resolve.

## “Checklist” names four different jobs

| Workflow | What its records establish |
| --- | --- |
| Deal checklist | The parties' item-by-item responses to extracted or manual terms, with versioned bilateral sign-off. |
| Negotiation checklist | Whether the operational conditions for closing are satisfied. |
| Field checklist | Responses and photo evidence collected during work at the property. |
| Transaction handover | Progress through identity checks, agreement, deposit and move-in tasks. |

The negotiation checklist has ten items. Four are derived from other records by `negotiationChecklist.computeAutoChecklistItems`: agreed terms, both proposal signatures, a collected **token advance** (an initial payment signalling intent to rent, say a hypothetical $200, with a recorded refund policy), and recorded brokerage. Six are managed manually: police verification, society permission, owner identity, rental agreement, key handover and move-in inspection.

Deriving the first four means the checklist cannot become a second, competing store of whether signatures or a payment record exist. Admins can waive eligible manual items with a reason, but `waiveItem` refuses the rental agreement, and marking the agreement complete requires an uploaded PDF.

Field checklists serve another purpose. Templates define inspection items; instances attach them to visits and assignees; response validation can require photographs; completeness is computed from required responses. A complete inspection is evidence of property work. It says nothing about whether tenant and owner accepted the same terms.

Collapsing these into one percentage would be tempting for a dashboard, and it would hide what is missing. “90% complete” is less useful than “both parties signed, but the agreement file is still missing.”

![Key handover pending with checklist progress five out of ten and closure creation disabled.](https://adityadalmia.com/_astro/checklist-before.CsQ-H8Qf_Z55BIn.webp)

*Local synthetic fixture, captured 2026-09-22. Before: this separate fixture starts from seeded agreed terms and token collection. Five of ten conditions are satisfied; key handover is pending.*

![Key handover completed, progress six out of ten, while closure creation remains disabled.](https://adityadalmia.com/_astro/checklist-after.BUpvl8kV_Z1FRggr.webp)

*After an actual checklist edit in the same fixture: key handover is completed and progress becomes 6/10. The remaining requirements still block closure. No real keys changed hands.*

## Closure checks the links, too

A negotiation reaches `READY_FOR_CLOSURE` when its checklist is satisfied. `reopenChecklist` can send it back to `DOCUMENTATION_IN_PROGRESS`; it needs admin rights, a management permission and a written reason of at least five characters. Reopening preserves item values and proposal signatures. It makes the checklist editable again; it does not reset the negotiation.

`closures.create` checks readiness when a negotiation is supplied, requires a linked visit and negotiation to refer to the same inquiry, and requires a supplied deal checklist to be approved and to match that inquiry. Those checks stop individually valid records being assembled into the wrong rental's completion record.

“When supplied” matters. The negotiation and deal-checklist links are optional arguments, so this implementation does not force every closure through both workflows. `closures.confirm` adds a separate gate: a linked rental transaction must be `COMPLETED`, and the transaction's own transitions check identity, agreement-signature and deposit records.

Recording a payment is also not moving money. Token and deposit records hold amounts, references and statuses. The identity-verification and e-signature actions return simulated results, and no code creates a payment order.

## What the evidence supports now

The project's work period was July to August 2026; this article reads the source on `main` as of September 23, after the counter-proposal and authorization fixes. The imported Git history does not date individual workflows.

Proposal signing now has a dedicated regression suite and a live check on the hosted demo, and deal-room access has its own suite. Most other workflow claims above come from reading the backend operations and their validation paths. Deal-checklist extraction, dispute resolution, closure-link validation and the transaction prerequisites still lack dedicated tests. They are the next ones to write, together with a full synthetic journey: progress one rental, attempt wrong-party and stale-version actions, reopen a requirement, and assert exactly which transition is refused.

Two smaller gaps are visible in the source. Marking a negotiation failed or expired does not archive its channels, even though archived channels already reject new messages. And, as above, the signer and chat resolvers still identify parties slightly differently.

The strongest part of the design is still the explicit difference between discussing a term, accepting a version, documenting the rental and recording completion. Each step has its own actor and evidence. The bug was a reminder that those distinctions only hold if the state machine connecting them is complete, and that the only reliable proof is a test that walks every path a real negotiation takes.

[Read the source](https://github.com/AdityaVikramDalmia/rental-platform-os), or [request demo access](https://adityadalmia.com/projects/rental-os/#demo-access) for a walkthrough of the deal room with synthetic data.
