Files
tessera/docs/rt-architecture-analysis.md
2026-06-07 23:32:04 +02:00

9.6 KiB

Request Tracker Architecture: Deep-Dive Technical Analysis

Source: RT 5.0.5 codebase analysis + official docs (docs.bestpractical.com)

Purpose: Reference for reimplementing RT's paradigm in Rust


1. SCRIPS — Event-Driven Automation Engine

Conceptual Model

A Scrip is a rule: "When X happens, if condition Y is met, execute action Z using template W."

Four primitives compose a scrip:

  • Condition (RT::ScripCondition): Has ApplicableTransTypes — comma-separated string of transaction types it matches (e.g., "Create,Correspond") or "Any". Modules like OnCreate, OnStatusChange, UserDefined.
  • Action (RT::ScripAction): Maps via ExecModule to a Perl module under RT::Action::* (SendEmail, AutoReply, CreateTickets, etc.). Has Argument field for parameterization.
  • Template (RT::Template): Global or queue-specific. Types: "Perl" (Text::Template) or "Simple" (variable substitution).
  • Stage: TransactionCreate (fires per-transaction) or TransactionBatch (fires once after batched updates).

Data Model

Scrips: id, Queue(0=global), Template(name), ScripCondition(id), ScripAction(id),
        Description, Disabled, CustomPrepareCode, CustomCommitCode, CustomIsApplicableCode
ObjectScrips: id, Scrip(FK), Stage, ObjectId(0=global or Queue id), SortOrder

Key insight: The same Scrip record can be applied to multiple queues via ObjectScrips, each with different Stage and SortOrder.

Dispatch Model — Prepare/Commit Two-Phase

Prepare phase (RT::Scrips::Prepare):

  1. Load Ticket + Transaction as SystemUser (bypass ACL)
  2. Find matching Scrips: global + queue-specific, filtered by Stage, matched by Condition.ApplicableTransTypes using SQL LIKE
  3. Sort by SortOrder
  4. For each: IsApplicable (no-side-effects check), then Prepare (builds message, determines recipients — no send), push to prepared_scrips

Commit phase (RT::Scrips::Commit): Iterate prepared_scrips in order, call Commit (actual side effects)

Dry-run mode: On Ticket Update page load, ALL scrips run in dry-run (Prepare only, no Commit) to populate the "Scrips and Recipients" preview.

Batch mode: With $UseTransactionBatch, multiple updates accumulate in _TransactionBatch. RanTransactionBatch flag prevents infinite loops. TransactionBatch-stage scrips see ALL batched transactions.

Invariants & Edge Cases

  • Prepare failures silently skip the scrip; Commit failures are NOT retried
  • Template scoping: queue-specific template overrides global for the same name
  • Global + queue-specific scrips both apply; their union runs sorted together
  • SystemUser escalation is essential — automation must not be gated by the triggering user's ACL
  • CustomIsApplicableCode/CustomPrepareCode/CustomCommitCode allow code injection at three points, guarded by ExecuteCode right
  • The Disabled boolean on Scrips is separate from the deprecated Disabled stage

2. TRANSACTION QUERY BUILDER

Conceptual Model

Five-layer pipeline: UI Tree → QueryBuilder::Tree (AST) → RT::SQL parser → RT::Tickets dispatch → DBIx::SearchBuilder SQL generation.

Transaction Model

Everything is a Transaction: Status changes, owner changes, comments, correspondence, CF updates, link changes, time worked — all create Transaction records.

Schema: id, ObjectType, ObjectId, Type, Field, OldValue, NewValue, Data, Creator, Created

Each transaction can have MIME attachments.

Dispatch Table Architecture

RT::Tickets::%FIELD_METADATA maps searchable fields to type handlers:

Status => [STRING], Queue => [QUEUE], Owner => [WATCHERFIELD => 'Owner'],
Requestor => [WATCHERFIELD => 'Requestor'], LinkedTo => [LINK => 'To'],
DependsOn => [LINK => To => 'DependsOn'], CF => [CUSTOMFIELD => 'Ticket'],
TxnCF => [CUSTOMFIELD => 'Transaction'], Content => [TRANSCONTENT],
Lifecycle => [LIFECYCLE], Created => [DATE => 'Created']

Each type dispatches to a handler (STRING → _StringLimit, WATCHERFIELD → _WatcherLimit, CUSTOMFIELD → _CustomFieldLimit, etc.) that generates appropriate SQL JOINs.

TicketSQL Parser

State-machine parser with states: KEYWORD, OP, VALUE, AGGREGATOR, OPEN_PAREN, CLOSE_PAREN.

Supports:

  • Cross-field references (Due < CF.{TargetDate})
  • IS/IS NOT NULL
  • LIKE/STARTSWITH/ENDSWITH
  • SHALLOW modifier

Key Optimizations

  • OR→IN conversion: (Status='new' OR Status='open' OR Status='stalled')Status IN ('new','open','stalled')
  • EntryAggregator intelligence: String fields default to OR for =, AND for !=; dates default to OR for equality, AND for ranges
  • Join deduplication: _sql_aliases hash tracks which JOINs exist, preventing duplicates
  • Post-filtering: Deleted tickets and ACL checks applied after SQL, not in SQL

Edge Cases

  • CF name ambiguity (same name, global vs queue-scoped) falls back to name-based query
  • IPAddressRange queries decompose = into two range comparisons
  • DateTime day-equality decomposes = into >= midnight AND < next midnight
  • NULL handling on CFs adds extra EXISTS check on CFs.Name
  • UseSQLForACLChecks injects ACL JOINs at SQL level

3. CUSTOM FIELDS

Attachment Model

Two join tables:

ObjectCustomFields: Maps CF to container (Queue id, or 0 for global). Controls which CFs appear. ObjectCustomFieldValues: Stores actual values on records. CustomField(FK), ObjectType, ObjectId, Content, LargeContent, Disabled.

LookupTypes encode the relationship: RT::Queue-RT::Ticket (ticket CFs), RT::Queue-RT::Ticket-RT::Transaction (txn CFs), RT::User, RT::Queue, RT::Group.

Typing System

13 types: Select, Freeform, Text, HTML, Wikitext, Image, Binary, Combobox, Autocomplete, Date, DateTime, IPAddress, IPAddressRange.

Each has: sort_order, selection_type flag, canonicalization flag, render types, labels.

Values system: MaxValues (0=unlimited, 1=single, N=capped), UniqueValues, ValuesClass (external sources), CanonicalizeClass (normalization), Pattern (validation regex), BasedOn (cascading), LinkValueTo (object linking).

Permissions

Five distinct rights: SeeCustomField, AdminCustomField, AdminCustomFieldValues, ModifyCustomField, SetInitialCustomField.

The last creates a "set-once-at-creation" pattern. Rights are contextual (per-queue for tickets).

Queryability

Notation: CF.{Name}, TxnCF.{Name}, QueueCF.{Name}, QueueID.CF.{Name}.Content, cross-field comparison.

Compilation: _CustomFieldDecipher resolves name/id to CF object → _CustomFieldJoin performs LEFT JOINs → _LimitCustomField applies operator with type-aware value parsing.

Sorting: ORDER BY CF.{Name} joins ObjectCustomFieldValues and, for selection types, CustomFieldValues to sort by SortOrder.

Edge Cases

  • Context-aware permissions (user sees CF on Queue A but not Queue B)
  • CF name collision detection and fallback
  • External values sources never stored locally
  • Content vs LargeContent transparent handling
  • SingleValue optimization (DISTINCT joins when MaxValues=1)

4. LIFECYCLES

Conceptual Model

A Lifecycle defines: valid statuses (initial/active/inactive), allowed transitions (directed graph), transition-gating rights, UI actions, defaults, and cross-lifecycle move maps.

Stored on Queue (not ticket); tickets inherit through queue.

State Machine Semantics

Date invariants:

  • Moving FROM initial TO active/inactive → set Started
  • Moving FROM initial/active TO inactive → set Resolved
  • Moving BACK from inactive → clear Resolved (critical!)
  • Moving FROM initial directly TO inactive → sets BOTH Started AND Resolved

Transition validation (ValidateStatusChange):

  1. Is new status valid in lifecycle?
  2. Is transition allowed? (IsTransition)
  3. Does user have the required right? (CheckRight with 4-level priority: exact→wildcard-from→wildcard-to→full-wildcard→fallback)

CheckRight priority: 'new→open' > '*→open' > 'new→*' > '*→*' > fallback (DeleteTicket for deleted, ModifyTicket otherwise).

Lifecycle rights REPLACE ModifyTicket, not supplement it.

Queue Changes

Move maps are directional ('default→orders' vs 'orders→default'). Maps MUST be complete — any missing status causes a hard error.

On queue change, if owner lacks OwnTicket on new queue, ticket is auto-reassigned to Nobody (safety invariant).

Rights Model

Custom named rights are auto-created: '* → rejected' => 'RejectTicket' creates a RejectTicket right.

No explicit guards for behavioral hooks — handled entirely through Scrips (OnStatusChange, OnResolve conditions).

Edge Cases

  • Action deduplication: specific 'from→to' overrides wildcard '*→to'
  • Lifecycle caching; no hot-reload without restart
  • Disabled lifecycles: existing queues keep working, can't be selected for new queues
  • Default statuses cover specific named contexts: on_create, approved, denied, reminder_on_open, reminder_on_resolve
  • Batched status changes: TransactionBatch scrips see all transactions atomically

Cross-Cutting Architectural Patterns

  1. Transaction-centric: Everything produces a transaction → unified audit trail, unified query interface, single Scrip event stream
  2. Container-scoped config: CFs, Scrips, Templates all follow global→queue override pattern
  3. Prepare/Commit: Separates planning from execution (essential for dry-run, safety)
  4. Dispatch tables: Field types → handlers, ApplicableTransTypes → conditions — config-driven, not code-driven extensibility
  5. SystemUser identity: Automation runs elevated; reimplementation needs explicit service account concept
  6. Aggressive caching: Lifecycle configs, CF join aliases, ACL roles, group memberships — essential at scale