Replicant Tenant Ticket Operations Design
Date: 2026-07-08<br> Status: Reviewed with Replicant/Fable5 decisions; Phase 1 client shipped, Phase 2 voice support pending<br> Audience: Riff engineering, Replicant engineering, Fable5 reviewers<br> Scope: Extend the live Riff <-> Replicant integration from identify/create/follow-up into tenant-scoped list, get, update, and soft-delete ticket operations.
Review Goal
Riff already has the right first three calls for voice maintenance intake:
POST /riff/identifyat call start.POST /riff/maintenance-ticketsto create a new maintenance request.POST /riff/ticket-followupto handle repeat callers asking for status or adding new information.
The new question is whether Riff should use a more generic operations pattern:
- Create a ticket.
- List all tickets visible to the tenant.
- Get a specific ticket.
- Update tenant-controlled ticket details.
- Soft-delete, cancel, or mark a ticket resolved.
This design says yes, with one constraint: the API should be generic at the operation level, not generic database CRUD. Replicant remains the system of record, owns authorization, owns workflow state, and decides which tenant-facing fields can change.
Recommendation
Keep the existing endpoints and add four tenant-scoped ticket operations:
| Operation | Endpoint | Purpose |
|---|---|---|
| Identify caller | POST /riff/identify |
Fast call-start lookup. Already live. |
| Create ticket | POST /riff/maintenance-tickets |
New issue intake. Already live. |
| List visible tickets | POST /riff/tickets/list |
Tenant asks what requests are open or recent. |
| Get ticket detail | POST /riff/tickets/get |
Tenant asks about one known ticket. |
| Update ticket | POST /riff/tickets/update |
Tenant adds information or changes tenant-owned fields. |
| Cancel ticket | POST /riff/tickets/cancel |
Tenant says the issue is resolved, duplicate, or no longer needed. |
Use POST for all operations, even read operations, because the existing HMAC
contract signs the raw request body. That keeps caller phone numbers, tenant
IDs, and ticket IDs out of URLs and access logs.
operation_id is required on mutating operations only: tickets/update and
tickets/cancel. Read operations (tickets/list and tickets/get) do not need
an operation ID.
Do not add DELETE. Riff should never ask Replicant to hard-delete a ticket.
The correct tenant-facing operation is cancellation, resolution, or a soft-delete
state that preserves audit history.
Do not add a POST /riff/tickets/create alias. New-ticket creation remains
POST /riff/maintenance-tickets.
Review Decisions
Replicant/Fable5 review confirmed:
| Question | Decision |
|---|---|
| HTTP shape | Keep POST with signed JSON body for every operation. |
| Read operation IDs | Do not send operation_id on list or get; require it only on update and cancel. |
| Create alias | Do not add tickets/create; keep the existing maintenance-tickets endpoint. |
| Pending reviews | Include pending_reviews in tickets/list. |
| Title/description edits | Never overwrite ticket title or description after creation. Use a tenant note, cancel/resolved state, or a new ticket for a new issue. |
| Authorization | Allow original submitter plus the primary tenant and LeaseTenant co-signers on the active lease of the ticket's unit. A phone number that matches a unit but has no lease record goes to pending review, not ticket access. |
| PM notifications | Selective PM email, always for severity escalation, cancellation requests, and "tenant says it is resolved." The final availability-window trigger wording is pending because the review note was truncated. |
Tenets
-
Replicant is the source of truth. Riff may send hints from
identify, but Replicant revalidates organization, tenant, phone, unit, and ticket ownership. -
A tenant updates intent, not internal workflow. Tenants can say "the leak is worse", "permission to enter is now yes", or "I fixed it." They cannot assign vendors, downgrade priority, edit internal notes, or move a ticket through Replicant's state machine directly.
-
Authorization is server-side.
caller_phoneis evidence, not proof by itself. Replicant should check that the caller is the submitter, an active tenant on the ticket's unit, or another authorized contact according to Replicant policy. -
Idempotency must handle multiple operations per call. Existing create and follow-up endpoints are idempotent on
call_sid. New mutation endpoints needoperation_idas well, because one phone call can update a ticket and then cancel it. -
Unknown and ambiguous callers still never lose work. If a caller cannot be matched to a tenant, create behavior should continue producing or appending to a pending voice intake review. The generic ticket API should not guess a unit from transcribed address text.
-
Every tenant mutation is auditable. Replicant should store actor type, call ID, operation ID, accepted fields, rejected fields, priority escalation, and the transcript URL when available.
Common Request Envelope
All endpoints use the current Replicant HMAC authentication:
| Header | Value |
|---|---|
X-Riff-Timestamp |
Unix timestamp in seconds |
X-Riff-Signature |
Hex HMAC-SHA256 over {timestamp}.{raw_body} |
Content-Type |
application/json |
Common fields:
{
"call_sid": "CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"organization_id": "uuid",
"caller_phone": "+15551234567",
"tenant_user_id": "uuid or null",
"unit_id": "uuid or null",
"transcript_url": "https://... optional"
}
Field rules:
| Field | Rule |
|---|---|
call_sid |
Required for call correlation and retry grouping. Keep the current name unless Replicant renames it to carrier-neutral call_id. |
operation_id |
Required for update and cancel only. Unique within a call. Do not send on list or get. |
organization_id |
Required when Riff has it from identify; Replicant must still verify the ticket belongs to that org. |
caller_phone |
Required. Normalize to E.164. |
tenant_user_id |
Optional hint from identify; not trusted by itself. |
unit_id |
Optional hint or filter from identify; not trusted by itself. |
transcript_url |
Optional. Only send a URL safe for Replicant to fetch and retain. |
For read-only operations, replaying the same request can return fresh data. For
mutating operations, Replicant should make {call_sid, operation_id} idempotent.
If the same body is replayed, return the original result with "duplicate": true. If the same key is replayed with a different body, return 409.
Operation 1: Create Ticket
Keep the existing endpoint:
POST /riff/maintenance-tickets
It already has useful behavior:
- Idempotent create on
call_sid. - Attach to tenant and unit when authoritative IDs are valid.
- Resolve from phone only when the match has exactly one unit.
- Create a pending voice intake review when the caller is unknown or ambiguous.
- Append repeat unknown calls to the same pending review.
No create alias should be added.
Operation 2: List Visible Tickets
Endpoint:
POST /riff/tickets/list
Use when the caller asks "what maintenance requests do I have?", "do I already have a ticket?", "what is open?", or when the follow-along web surface needs a tenant ticket list.
"All tickets" means all tickets visible to this caller, not all organization tickets.
Request:
{
"call_sid": "CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"organization_id": "uuid",
"caller_phone": "+15551234567",
"tenant_user_id": "uuid or null",
"unit_id": "uuid or null",
"filters": {
"scope": "OPEN_OR_RECENT",
"include_recently_closed_days": 14,
"limit": 10,
"cursor": null
}
}
Response:
{
"tickets": [
{
"ticket_id": "uuid",
"title": "Kitchen sink leaking",
"category": "PLUMBING",
"status": "SCHEDULED",
"priority": "NORMAL",
"status_summary": "A vendor, Best Plumbers, is scheduled to visit on July 8 between 9:00 AM and 11:00 AM.",
"created_at": "2026-07-01T18:04:00Z",
"updated_at": "2026-07-07T22:11:00Z",
"schedule": {
"scheduled_date": "2026-07-08",
"arrival_window_start": "09:00",
"arrival_window_end": "11:00",
"timezone": "America/Los_Angeles",
"vendor_company_name": "Best Plumbers"
},
"can_update": [
"tenant_update_text",
"permission_to_enter",
"entry_notes",
"availability_windows",
"preferred_contact_method",
"severity_hint",
"issue_resolved_by_tenant"
],
"allowed_actions": ["get", "update", "cancel"]
}
],
"pending_reviews": [
{
"review_id": "uuid",
"title": "Bathroom ceiling leak",
"status": "PENDING_REVIEW",
"status_summary": "Your maintenance request is pending property manager review.",
"created_at": "2026-07-08T16:20:00Z",
"allowed_actions": ["append_update"]
}
],
"next_cursor": null
}
Notes:
identify.open_ticketsremains the fast call-start shortcut.tickets/listis the fuller operation when the caller asks for more than the newest open tickets.pending_reviewsshould be included. It lets Riff avoid telling an unknown caller "there are no tickets" when Replicant has an unconverted voice intake review for that phone number.status_summaryshould stay human-readable and safe to read aloud.
Operation 3: Get Ticket Detail
Endpoint:
POST /riff/tickets/get
Use when the caller identifies one ticket and wants status, schedule, public notes, access details, or a read-back before changing it.
Request:
{
"call_sid": "CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"organization_id": "uuid",
"ticket_id": "uuid",
"caller_phone": "+15551234567",
"include": ["timeline", "public_notes", "access", "schedule"]
}
Response:
{
"ticket": {
"ticket_id": "uuid",
"title": "Kitchen sink leaking",
"category": "PLUMBING",
"status": "SCHEDULED",
"priority": "NORMAL",
"status_summary": "A vendor, Best Plumbers, is scheduled to visit on July 8 between 9:00 AM and 11:00 AM.",
"created_at": "2026-07-01T18:04:00Z",
"updated_at": "2026-07-07T22:11:00Z",
"permission_to_enter": true,
"entry_instructions": "Cat in unit; call 30 minutes ahead.",
"schedule": {
"scheduled_date": "2026-07-08",
"arrival_window_start": "09:00",
"arrival_window_end": "11:00",
"timezone": "America/Los_Angeles",
"vendor_company_name": "Best Plumbers"
},
"tenant_visible_timeline": [
{
"at": "2026-07-01T18:04:00Z",
"type": "SUBMITTED",
"summary": "Request submitted by phone."
},
{
"at": "2026-07-07T22:11:00Z",
"type": "SCHEDULED",
"summary": "Vendor visit scheduled."
}
],
"can_update": [
"tenant_update_text",
"permission_to_enter",
"entry_notes",
"availability_windows",
"preferred_contact_method",
"severity_hint",
"issue_resolved_by_tenant"
],
"allowed_actions": ["update", "cancel"]
}
}
Safe detail fields are: title, category, status and status summary, priority,
created/updated timestamps, schedule block with vendor company name only,
permission_to_enter, entry instructions, and a tenant-visible timeline built
from MaintenanceNote(is_visible_to_tenant=True) plus status-transition audit
events.
Replicant should not expose description rewrites after creation, internal PM notes, vendor private contact information such as phone numbers, money-bearing fields such as estimates, quotes, invoices, bids, costs, internal triage messages, or fields that the tenant could not already see in Replicant's tenant-facing surfaces.
Operation 4: Update Ticket
Endpoint:
POST /riff/tickets/update
Use when the caller adds information, corrects details, changes access permission, gives availability, changes contact preference, reports that the problem is worse, or says the issue may be resolved but does not clearly cancel the ticket.
Request:
{
"call_sid": "CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"operation_id": "CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:update:0003",
"organization_id": "uuid",
"ticket_id": "uuid",
"caller_phone": "+15551234567",
"updates": {
"tenant_update_text": "The leak has become a steady stream and water is spreading under the cabinet.",
"permission_to_enter": true,
"entry_notes": "Cat in unit; call 30 minutes ahead.",
"availability_windows": [
{
"date": "2026-07-09",
"start_time": "09:00",
"end_time": "12:00",
"timezone": "America/Los_Angeles"
}
],
"preferred_contact_method": "SMS",
"severity_hint": "EMERGENCY",
"issue_resolved_by_tenant": false
},
"transcript_url": "https://... optional"
}
Response:
{
"ticket_id": "uuid",
"status": "SCHEDULED",
"priority": "EMERGENCY",
"status_summary": "This request has been escalated as an emergency. A property manager will contact you shortly.",
"accepted_updates": [
"tenant_update_text",
"permission_to_enter",
"entry_notes",
"availability_windows",
"preferred_contact_method",
"severity_hint"
],
"rejected_updates": [],
"escalated": true,
"new_priority": "EMERGENCY",
"note_id": "uuid",
"duplicate": false
}
Tenant-writable fields:
| Field | Allowed behavior |
|---|---|
tenant_update_text |
Add a tenant-visible note and notify the PM when material. |
permission_to_enter |
Update access permission while preserving audit history. |
entry_notes |
Update pets, gate codes, call-ahead requests, or access constraints. |
availability_windows |
Store tenant availability. If a vendor is already scheduled, notify PM instead of silently rescheduling. |
preferred_contact_method |
Store preference, but Replicant still enforces SMS consent server-side. |
severity_hint |
Upgrade-only. Emergency and urgent can raise priority; routine never downgrades. |
issue_resolved_by_tenant |
If true but cancellation is ambiguous, add a note and return the current status. |
title / description |
Never overwrite after creation. Corrections become tenant notes; a different issue becomes a new ticket. |
Tenant-forbidden fields:
| Field class | Reason |
|---|---|
| Organization, tenant, lease, unit IDs | Attachment must remain authoritative in Replicant. |
| Workflow status | Tenants should express intent; Replicant moves state. |
| Priority downgrade | Safety issue. Allow upgrade signals only. |
| Vendor assignment and vendor private details | Internal dispatch control. |
| Internal PM notes | Not tenant-visible. |
| Billing, bids, approvals, invoice fields | Out of scope for voice maintenance intake. |
| Hard deletion flags | Audit trail must remain intact. |
If the request includes unsupported fields, Replicant should ignore them and
return rejected_updates with field-level reasons. Do not fail the entire
request when at least one tenant-safe update can be accepted.
Operation 5: Cancel Or Soft-Delete Ticket
Endpoint:
POST /riff/tickets/cancel
Use when the caller says the problem is fixed, the ticket is a duplicate, the request was a mistake, or they no longer need service.
Request:
{
"call_sid": "CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"operation_id": "CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:cancel:0004",
"organization_id": "uuid",
"ticket_id": "uuid",
"caller_phone": "+15551234567",
"reason": "The tenant says the leak stopped after tightening the valve.",
"issue_resolved_by_tenant": true,
"transcript_url": "https://... optional"
}
Response:
{
"ticket_id": "uuid",
"status": "CANCELLED",
"cancellation_state": "CANCELLED",
"status_summary": "This maintenance request has been cancelled. If the issue returns, call back and we can reopen or create a new request.",
"note_id": "uuid",
"duplicate": false
}
The response above shows the ideal simple case. Replicant may need different behavior depending on current workflow state:
| Current state | Recommended behavior |
|---|---|
SUBMITTED, TRIAGED, AWAITING_APPROVAL, APPROVED |
Cancel immediately, preserve audit trail, return cancellation_state: "CANCELLED". |
VENDOR_ASSIGNED, SCHEDULED, IN_PROGRESS, EMERGENCY_DISPATCHED |
Record a cancellation request, notify PM/vendor workflow, return cancellation_state: "REQUESTED". |
COMPLETED, CLOSED, CANCELLED |
Return current state with duplicate: true or cancellation_state: "NOOP". |
Suggested cancellation states:
CANCELLED
REQUESTED
NOOP
REJECTED
This is the tenant-facing "soft delete" operation. It should never physically delete Replicant records.
Relationship To ticket-followup
Keep POST /riff/ticket-followup for the current voice path. It is useful
because it compresses the most common repeat-caller cases into one endpoint.
The new operations can become the lower-level contract behind it:
| Existing follow-up case | New-operation equivalent |
|---|---|
STATUS_INQUIRY |
tickets/get, plus an audit event that the tenant asked for status. |
TENANT_UPDATE with new information |
tickets/update with tenant_update_text, severity_hint, and transcript URL. |
| Tenant asks about all requests | tickets/list, then optionally tickets/get. |
| Tenant says fixed/cancel | tickets/cancel. |
Riff can start with the existing follow-up endpoint for simple calls and use the
new operations when the caller asks for richer behavior. Replicant can implement
ticket-followup as a wrapper internally if that reduces duplicate business
logic.
Conversation Behavior
At call start:
- Riff calls
identifyduring greeting playback. - If
open_ticketsis non-empty, Riff offers the most likely follow-up: "Are you calling about the kitchen sink leak?" - If there are multiple open tickets or the caller asks broadly, Riff calls
tickets/list.
During the call:
| Caller intent | Riff operation |
|---|---|
| "I need to report a new issue." | maintenance-tickets |
| "What tickets do I have?" | tickets/list |
| "What's happening with the sink leak?" | tickets/get or ticket-followup status inquiry |
| "The leak is worse now." | tickets/update or ticket-followup tenant update |
| "The cat is in the unit; call first." | tickets/update |
| "I fixed it; cancel the request." | tickets/cancel |
| "This is a different problem." | New maintenance-tickets request, not an update |
Closing language should match the actual response:
- If an update succeeds: "I've added that to the request."
- If Replicant escalates: "I've raised this to emergency priority."
- If cancellation is immediate: "That request has been cancelled."
- If cancellation is only requested: "I've sent the cancellation request to the property manager."
- If a retry is queued locally after timeout: "I've recorded that update and will send it through. If the property manager needs more information, they'll contact you." Do not claim Replicant already changed state.
Retry And Degradation
| Failure | Riff behavior |
|---|---|
list / get timeout or 5xx |
Fall back to identify.open_tickets if available; otherwise explain that status is temporarily unavailable. No durable retry required unless Replicant wants inquiry audit. |
update / cancel timeout or 5xx |
Queue a durable retry with the same {call_sid, operation_id} for up to 24 hours. |
update / cancel replay |
Replicant returns the original result with duplicate: true. |
| Same idempotency key, different body | Return 409; Riff should log and alert. |
401 / 403 |
Re-sign and retry once with a fresh timestamp. If still rejected, log and alert. |
404 |
Treat as nonexistent or unauthorized. Do not retry. Offer new-ticket intake if appropriate. |
409 state conflict |
Fetch current ticket state with tickets/get; continue from the new truth. |
422 or other 4xx |
Contract bug. Do not retry; log and alert with redacted payload summary. |
429 |
Retry with backoff if the response permits it; preserve operation ID. |
Audit And Observability
Every mutating operation should create an audit event in Replicant with:
actor_type: "AI_VOICE_TENANT".caller_phone, preferably stored according to Replicant's PII policy.call_sid.operation_id.- Endpoint and normalized operation type.
- Accepted and rejected fields.
- Previous and new values for tenant-writable fields where safe.
- Priority escalation result.
transcript_urlwhen provided.- Source
riff.
Riff should log:
- Endpoint, status code, latency, retry decision, and operation ID.
- Redacted field names, not raw tenant PII.
- The ticket ID or review ID only where logs are access-controlled.
Security And Privacy
- Continue using the existing HMAC authentication and 300-second replay window.
- Keep clocks NTP-synced on both sides.
- Do not put caller phone numbers, ticket IDs, tenant names, or addresses in URL query strings.
- Replicant must authorize every ticket operation server-side.
- Replicant should return
404for unauthorized ticket access so callers cannot enumerate ticket IDs. - Authorized callers are the original submitter plus the primary tenant and
LeaseTenantco-signers on the active lease of the ticket's unit. A phone number that matches a unit but has no lease record should go to pending review, not ticket access. - Riff should not persist full ticket details beyond the live call context and durable retry queue.
- SMS consent remains Replicant-owned.
preferred_contact_method: "SMS"is a preference, not consent.
Pending Voice Intake Reviews
The existing create behavior can produce a pending voice intake review instead of an attached ticket. This should remain.
Recommended handling:
maintenance-ticketscontinues to append repeat unknown-caller descriptions to the same pending review.tickets/listshould returnpending_reviewsfor the caller phone so Riff can acknowledge that the request is in review.- Direct
tickets/updateonreview_idis optional for Phase 1. If Replicant wants it, use a sibling endpoint such asPOST /riff/reviews/updateor allowtickets/updateto accept{ "review_id": "uuid" }when noticket_idis present.
Replicant/Fable5 confirmed pending reviews should be listed in the same response as tickets.
Implementation Phases
Phase 0: Contract Review
- Replicant and Fable5 reviewed this endpoint shape.
operation_idis required for mutating operations only.- Tenant-writable fields and cancellation semantics are confirmed above.
Phase 1: Riff Client And Fixtures
- Add
ReplicantClient.list_tickets. - Add
ReplicantClient.get_ticket. - Add
ReplicantClient.update_ticket. - Add
ReplicantClient.cancel_ticket. - Add signature and payload fixture tests for each method.
- Add durable retry spool keyed by
{call_sid, operation_id}for update/cancel.
Phase 2: Voice Conversation Support
- Teach the property flow to branch among new ticket, status inquiry, update, and cancel.
- Use
identify.open_ticketsfirst, thentickets/listwhen the caller asks broadly or the first five tickets are insufficient. - Add simulator cases for list/get/update/cancel.
Phase 3: Follow-Along Web Support
- Show tenant-visible tickets on the companion page after identity is verified.
- Allow tenant-safe update fields in the web intake surface.
- Never expose internal Replicant fields to the browser.
Phase 4: OTA And Replay Evidence
- Run M3 simulator scenarios against mock Replicant fixtures.
- Run one human-approved OTA call for status inquiry, one for update, and one for cancellation once Replicant staging or production test data exists.
- Promote only after trace artifacts prove correct endpoint choice and retry behavior.
Acceptance Criteria
- Riff can list tenant-visible tickets without exposing org-wide data.
- Riff can read one ticket status and safely speak
status_summary. - Riff can update only tenant-owned fields.
- Riff can queue update/cancel retries without duplicate Replicant mutations.
- Replicant rejects unauthorized ticket access as
404. - Cancellation preserves audit history and never hard-deletes.
- Unknown or ambiguous callers still create or append pending intake review records.
- Tests cover success, timeout retry, duplicate replay, unauthorized 404, conflict 409, unsupported field rejection, and priority escalation.
Fable5 Review Questions
Closed by review except one truncated item:
- Keep
POST /riff/tickets/list|get|update|cancelwith signed request bodies. - Require
operation_idon mutations only. - Keep
POST /riff/maintenance-ticketsas the only create endpoint. - Cancellation status behavior is specified in Operation 5.
- Safe detail fields are specified in Operation 3.
- Include
pending_reviewsintickets/list. - Never overwrite title or description after creation.
- Authorization is original submitter plus primary tenant and active-lease co-signers; unit-phone-only matches go to pending review.
- Use selective PM email, always for severity escalation, cancellation requests, and tenant-resolved signals. Confirm the truncated availability-window trigger wording before Phase 2.
ticket-followuppublic-endpoint retirement was not covered in the provided review excerpt; keep the current relationship section until Replicant says otherwise.