← Choice Health 2026-07-08-replicant-ticket-operations-design.md raw .md

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:

  1. POST /riff/identify at call start.
  2. POST /riff/maintenance-tickets to create a new maintenance request.
  3. POST /riff/ticket-followup to handle repeat callers asking for status or adding new information.

The new question is whether Riff should use a more generic operations pattern:

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

  1. Replicant is the source of truth. Riff may send hints from identify, but Replicant revalidates organization, tenant, phone, unit, and ticket ownership.

  2. 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.

  3. Authorization is server-side. caller_phone is 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.

  4. Idempotency must handle multiple operations per call. Existing create and follow-up endpoints are idempotent on call_sid. New mutation endpoints need operation_id as well, because one phone call can update a ticket and then cancel it.

  5. 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.

  6. 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:

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:

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:

  1. Riff calls identify during greeting playback.
  2. If open_tickets is non-empty, Riff offers the most likely follow-up: "Are you calling about the kitchen sink leak?"
  3. 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:

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:

Riff should log:

Security And Privacy

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:

  1. maintenance-tickets continues to append repeat unknown-caller descriptions to the same pending review.
  2. tickets/list should return pending_reviews for the caller phone so Riff can acknowledge that the request is in review.
  3. Direct tickets/update on review_id is optional for Phase 1. If Replicant wants it, use a sibling endpoint such as POST /riff/reviews/update or allow tickets/update to accept { "review_id": "uuid" } when no ticket_id is present.

Replicant/Fable5 confirmed pending reviews should be listed in the same response as tickets.

Implementation Phases

Phase 0: Contract Review

Phase 1: Riff Client And Fixtures

Phase 2: Voice Conversation Support

Phase 3: Follow-Along Web Support

Phase 4: OTA And Replay Evidence

Acceptance Criteria

Fable5 Review Questions

Closed by review except one truncated item:

  1. Keep POST /riff/tickets/list|get|update|cancel with signed request bodies.
  2. Require operation_id on mutations only.
  3. Keep POST /riff/maintenance-tickets as the only create endpoint.
  4. Cancellation status behavior is specified in Operation 5.
  5. Safe detail fields are specified in Operation 3.
  6. Include pending_reviews in tickets/list.
  7. Never overwrite title or description after creation.
  8. Authorization is original submitter plus primary tenant and active-lease co-signers; unit-phone-only matches go to pending review.
  9. Use selective PM email, always for severity escalation, cancellation requests, and tenant-resolved signals. Confirm the truncated availability-window trigger wording before Phase 2.
  10. ticket-followup public-endpoint retirement was not covered in the provided review excerpt; keep the current relationship section until Replicant says otherwise.