Status: RESEARCH NOTE (pre-plan). No code. Per docs/PROCESS.md §1.1, exploratory analysis lives in docs/research/ until committed to a sprint. Date: 2026-07-23 Provenance: reconstructed in conversation from the operator's memory of a computer-repair shop run ~6 years ago on osTicket. Department codes are approximate (recalled, not read from the system). The structure — court alternation, dept-as-area, template-per-dept, guards — was described consistently across the full flow and is the critical-path content. The original osTicket instance and its data still exist and are intended to be ported; that data is ground truth and supersedes this note wherever they disagree.
Related: `attestation-and-informed-consent.md` (the intake agreement, custody media, disposal discipline), `red-lab.md`.
---
1. What this actually was
osTicket, bent into a repair-shop pipeline. osTicket ships Departments, Canned Responses, and random ticket numbers natively. It does not ship a workflow engine — its own status field is coarse (open / resolved / closed). So Departments were repurposed as workflow states, because a Department was the only per-stage container that also carried its own canned responses.
Everything that made it work was operational discipline layered on top, enforced in plastic and marker — none of it existed in the software:
- each department bound to a physical area (the CI shelf, the data-recovery bench, the PU rack)
- the tub as the custody unit: machine + ordered parts + components taped and kept together
- dry-erase label on each tub rewritten to ticket + dept + date, so aging was ambient
- guards between departments (payment clears before DIAG; approval before WFO)
- court alternation — whose turn it is — which appears nowhere in osTicket
The IP is the process, not the code.
---
2. The department model
A department is not an org unit. It is a state node that owns six things:
| facet | meaning |
|---|---|
| area | the physical shelf / bench / room. The dept is where to walk. |
| court | who owns the next action: us, customer, or vendor |
| options | the actions legal here — and only those |
| responses | the canned message(s) fired on entry |
| SOP | the written procedure followed at this stage |
| dwell budget | how long is too long here (per-dept, not global) |
2.1 The flow (codes approximate)
CI → DIAG → WFC → WFO → WFP → WFT → [DATA] → OS → SET → WFI → PU
| dept | expansion | court | area | message on entry | exit guard |
|---|---|---|---|---|---|
| CI | check in | customer | check-in shelf | "checked in" + payment link for the exam fee | exam fee paid |
| DIAG | diagnosis | us | bench | "we have begun…" | diagnosis complete (work product: notes) |
| WFC | waiting for customer | customer | — | the quote: parts + labor − deposit | customer approves and pays for the part |
| WFO | waiting for order | us | — | (none — this is a to-do) | order placed |
| WFP | waiting for part | vendor/carrier | — | tracking number | part arrives |
| WFT | waiting for tech | us | — | "your part is here, we'll help you soon" | tech starts |
| DATA | data recovery | us | data-recovery area | "started" → later "your data is recovered" | recovery complete |
| OS | OS install | us | bench | install started | install complete |
| SET | setup | us | bench | restoring data, programs, drivers | setup complete |
| WFI | waiting for invoice | us (admin) | — | (Kat invoices, then calls to say it's ready) | invoiced + customer called |
| PU | pickup | customer | pickup rack | (on collection: thanks + receipt) | paid + collected |
Then: marked picked up in the ticket system → closed → reopened for follow-up (review request / schedule a home visit).
2.2 The court alternation is the process
us → customer → us → customer → **us** → vendor → us → … → admin → customer
Court is a first-class attribute, not derived. It determines three things no status field can:
- who to chase when it stalls
- whether the delay is our fault
- which escalation applies
Consequence for the board: group by court, ours at the top. Everything in an "us" column is a to-do list; everything in a "them" column is a follow-up list. No dashboard cleverness required.
2.3 "Blocked on us" is not one bucket
- WFT is blocked on tech time.
- WFI is blocked on admin time (a different person, a different skill, a phone call).
A machine aging in WFI is not a tech problem no matter how long it sits. The board needs a bench list and a front-desk list, not one "our court" pile.
2.4 WFO is the most damning state in the system
The customer has already paid for a part and nobody has placed the order. Aging in WFO is not waiting — it is a dropped ball with their money already in the account. If one number goes in red on a wall display, it is oldest item in WFO.
2.5 The two waiting states need different overdue logic
| blocked on | has an ETA? | escalation | |
|---|---|---|---|
| WFC | customer | no — open-ended | escalating contact ladder; record each attempt |
| WFP | carrier | yes — the promised delivery date | measure against ETA; chase the supplier |
Same "blocked" concept, two completely different mechanics. Neither is an SLA (§4).
2.6 Dept occupancy is a financial position
Once the customer has paid for the part, WFO and WFP are a liability: money held against a part not received, for work not done. Summing dept occupancy yields, for free:
- Σ WFO + WFP = customer money held against undelivered parts (deferred revenue)
- Σ WFC = quoted work stalled awaiting approval
No separate report — the same board that shows aging shows exposure.
---
3. Physical/digital coherence
3.1 Location IS state
Moving the tub to the DIAG bench is the DIAG transition. Consequences:
- Self-enforcing. A tub is physically in exactly one place, so "one object, one state" needs no
constraint. (Contrast domain-inventory::StockPlacement, which permits one item active in two locations — correct for fungible SKUs, wrong here.)
- Audit is a walk, not a query. The DB is a claim about the room; coupling makes the claim
checkable by eye. Decouple them and the DB can assert "ready for pickup" about a machine on the diagnosis bench, and nothing will ever notice.
- DATA proves it is critical-path, not merely tidy: *"we knew it was in the data-recovery area or
else on the bench."* The dept is the physical index — which room to walk into.
3.2 Eligibility ≠ transition
Payment clearing must not advance the state. If it did, the DB would claim DIAG while the tub sat on the CI shelf, destroying §3.1.
- payment cleared → the item becomes eligible to advance
- someone physically moves the tub → the state transitions
The gap between those two is a work queue — "paid and waiting to be moved" — and it is a different list from the aging list. One says "nobody has touched this"; the other says "this is ready and blocked on us." Both belong on the board.
3.3 The tub is the custody unit, not the machine
A ticket = a tub = a set of physical objects that must stay together and leave together: the machine, ordered parts (added at WFT), and components pulled and labeled with blue painter's tape. Losing a screw is a custody failure; the tape was the enforcement.
Nothing in the framework models "these N physical objects belong to one job and must not be separated." storage-locations has Container/Bin; inventory has item↔location. Neither has the kit.
---
4. Dwell is not SLA
SLA is a commitment measure: a promised target, breach is a contract event, customer-visible, may carry a penalty. Time-to-respond, time-to-fix, packet loss.
Dwell is operational hygiene. Nobody promised the customer their machine would leave BIN-14 within ten days. The date on the tub existed so we would notice.
The coverage sets are nearly opposite:
- SLA watches what you already committed to — so someone is already thinking about it.
- Dwell watches what nobody promised anything about — which is exactly where things rot.
The machine that sat until the drive filled had no SLA because nobody was tracking it. It was invisible to the promise system precisely because no promise existed.
Verified: operations-sla cannot express dwell. Every clock is input.created_at + policy duration; SlaInput has no state field. Two milestones are hard-coded (first response, resolution). Additionally its pause_on_waiting field exists, defaults true, has a builder — and tracker.rs never reads it. WFC is the state that flag was invented for, and it silently does nothing.
Aging itself is arithmetic (now − entered_state_at), not a missing capability. What is missing:
1. A trustworthy `entered_state_at`. updated_at moves on any write; touch a note and the item looks fresh. operations-workflow's workflow_transition_history.at supplies this correctly — but only for entities using the CAS store, and nothing projects "current state entered at." 2. A per-state budget. Three days in WFC is normal; three days in WFO is a dropped ball. 3. Ambience. The tub date worked because it was visible across the room without asking. Reports are pull systems; the tub wall was a push system.
---
5. Identifiers
5.1 Ticket number: random 6-digit — and random is critical-path
Requirements, in priority order: perceptually distinct, memorable, speakable, writable on tape. Not "unguessable."
- Sequential fails the physical hop.
100234and100235on adjacent tubs; one misread digit
puts the wrong part in the wrong machine. Random maximizes practical edit distance.
- Sequential also leaks business volume to every customer.
- Rendered 3-over-3 on the tape — narrow tape, so two rows keep the digits large enough to read
across a room after handling; and 3+3 chunking measurably reduces transcription error (the phone-number ergonomic).
It is a shared reference across four surfaces — tape on the tub, the customer's email, the staff search box, and spoken aloud on the phone. Four hops, three media; the number must survive all of them. That is why "easy to tell apart, easy to remember" was the right requirement.
It was never a credential. Authentication was email + password, with real sessions and permissions. The ticket number appeared in emails as a reference and was the staff search key.
5.2 Two identifier needs, opposite allocators
| shape | why | framework fit | |
|---|---|---|---|
| BIN-NN | sequential, gapless, never reused | a fixed physical set; a gap means a missing shelf | forge_allocate_counter (application-core/src/sql_invariants.rs:129) — DB-atomic, gapless, rollback burns nothing |
| Ticket # | random, unique, memorable, distinct | anti-misread across physical surfaces | absent — every framework allocator is gapless-sequential; the two random 6-char generators (reservations, scheduling, copy-pasted) are collision-unchecked |
Also note `foundation/sequence` is split-brain: the SQL get_next_sequence_value() is genuinely gapless (SELECT … FOR UPDATE), but the Rust Sequence/TemporalSequence types are in-memory &mut self counters with zero `PgPool`. Using the Rust API to allocate BIN-14 gives no gaplessness and no concurrency safety.
The identifier's canonical rendering belongs in the library crate (123456 stored, 123 / 456 on tape, spoken "one twenty-three, four fifty-six"), or surfaces will drift and someone will hand-write a 6-run that gets misread. SequenceFormat supports single-line templates and assumes a screen.
---
6. Why the SOP-per-department mattered
The state tells you what to do. A worker does not need the whole process in their head — only what is legal from where they are.
- In CI you cannot order parts. In WFO you cannot move it to the pickup rack.
- Whole classes of mistake are prevented by not offering the wrong action.
This is what let the shop run with smart 17-year-olds three weeks in: correct execution did not depend on expertise. It is also the protective property from the intake design in another costume — fewer ways to make an expensive mistake with someone else's property.
Silence becomes structurally impossible. The message is attached to the state, not to a person's judgment, so a customer cannot be forgotten between stages. That kills the "where's my computer?" call — a real cost line, since every one interrupts someone holding a screwdriver. It matters most during DATA, where the wait is longest and the anxiety highest: started → recovered → installing → setting up, with nobody deciding to send any of it.
Template complexity varies by dept: CI is a fixed string; WFC is a computed document (parts + labor − deposit); WFP is assembled from the order record (tracking number).
6.1 Court transfer is event-driven in BOTH directions
- Outbound message → the item moves to the customer's court (e.g. WFC).
- Inbound customer reply → the system moves the ticket into a check state → back to our
court, visibly.
Nobody has to notice that a customer answered; the reply itself moves it. So inbound communication is a state transition, not merely an append to a message thread — which is how most ticket systems treat it, and why their boards drift out of date.
This is what makes WFC survivable. The only WFC items that rot are those where the customer never replies at all — precisely the abandonment branch (§9), and the one case this mechanism cannot catch.
6.2 The email is a pointer, not a payload
Customers are notified by email that there is an update on ticket 123456, with a link to the portal — or they simply log in. The email carries the reference and the pointer; the content stays behind authentication.
The primary reason is revisability, not privacy (per the operator — this note originally had it the wrong way round):
**Email is an irrevocable publish. The portal is a revisable document.**
Content mailed out is permanent and uncorrectable. Content behind a link stays editable right up until the customer looks at it. That window was used constantly and for three distinct reasons:
1. The analysis changed. Further diagnosis revised the finding. 2. The price changed. "Sorry, the price has changed" — a revised quote, before the old one became a commitment they had read. 3. The human reason. Something written in frustration could be walked back. In the operator's words: "someone got pissed off (me) and then had to be like — oh good, they didn't check yet."
That third one is a real design requirement, not a flaw to be embarrassed about. In a business where you routinely tell people their machine is dead and it will cost $400, a system that grants a beat to reconsider protects the customer from your worst hour and protects you from your own. Most tools make sending something unretractable frictionless.
This makes view-state part of the model, not telemetry. Whether the customer has looked yet defines the correction window:
- not viewed → still editable; correct it silently
- viewed → it has landed; corrections must now be an explicit, visible correction
A design consequence: the library crate needs a viewed_at on the delivered document, and the editor must surface the window's state — because "can I still fix this?" is the question actually being asked.
Privacy is the secondary benefit, and it is real: email is insecure in transit, resident in an inbox indefinitely, synced to phones (possibly the phone attached to the machine being repaired), and forwarded. The quote carries pricing; DIAG notes may describe things the customer would not want sitting in a mail account for a decade.
So a department's template is not one thing — it is a template plus a per-channel disclosure policy. The same state change yields graduated detail by how trustworthy the delivery surface is:
| channel | carries |
|---|---|
| portal (authenticated session) | the full document — quote line items, notes, tracking |
| ticket reference + "there is an update" + link | |
| SMS (if ever) | less still |
This is the framework's secure-media rule (.claude/CLAUDE.md rule 10) in a different domain: content lives behind the gated route; what leaves the building is a pointer, never the thing.
Design decision, settled: "secure link" means a link to a page requiring login, not a magic link carrying a token. A magic link is a bearer credential sitting in an inbox — and inboxes get compromised, shared, and left signed-in on old devices. Since real sessions and passwords already exist, the link is a convenience pointing at authentication, never a substitute for it.
---
7. The gap that caused the dd
The machine leaves at PU. The data copy does not.
Two custody objects, two different endings:
| object | custody discharged | tracked? |
|---|---|---|
| the machine | at PU — signed off, receipt issued | yes, by the dept board |
| the working data copy | only at wipe — after confirmed satisfaction | no dept, no shelf, no court, nobody's job |
The second obligation outlives the first and is invisible to the system that tracked the first. The ticket feels finished the moment the customer leaves with a receipt. Nothing was watching, because the thing being watched had left the building.
So the aging problem was never mainly tubs sitting too long. The longest-lived custody obligation had no representation at all, and the only thing that eventually surfaced it was the disk filling — at which point disposal happened under capacity pressure, on whatever drive someone grabbed, for tickets that were still open.
7.1 The fix falls out of the same vocabulary
A department for the data copy — WFW, waiting for wipe — entered at PU, exited only by a verified destruction, with the ticket unable to close until it is empty. Then "close the ticket" and "destroy the copy" become one act.
7.2 The confirmation event already existed
The reopen-for-follow-up (review request / schedule a home visit) is the disposal gate. You call to ask how it is running, they say "great, everything's back" — that sentence is the customer confirms satisfied that authorizes the wipe. The marketing touch and the custody discharge were always one conversation; it just was not formalized.
7.3 Disposal triggers, ranked
1. Confirmed handoff — correct. Data returned, organized, programs reinstalled, machine set up "like it was or better," customer confirms. Then dd. Then note it in the ticket. Then close. 2. Recorded exhausted contact — the correct backstop. Follow-up attempted and re-attempted, customer unreachable. Driven by attempts recorded, not days elapsed. 3. Capacity pressure — what actually fires. Inevitable with finite media.
A fixed retention timer is wrong, confirmed in practice: customer return times are irreducibly unpredictable, so any fixed period is either too short (destroys the safety net someone still needs) or so long it stops meaning anything. And a clock is equally wrong as a backstop — that reintroduces the same defect one level down.
Since capacity pressure cannot be legislated away, the system's job is to guarantee that when it fires, it fires on eligible copies: track eligibility continuously, surface a reclaimable worklist ("N copies eligible, X GB"), require a recorded override to destroy an ineligible one, and treat a rising count of unclosed tickets as the leading indicator.
---
8. Closed is not terminal
Reopen-for-follow-up is a normal path, not an exception — which most state machines get wrong, since is_terminal() usually means "never again."
But the reopen is doing double duty: the repair is finished; what wakes up is the customer relationship (review, home visit, the ongoing paid support). Strictly these are different objects — encounters for the visit, parties for the person.
Counter-argument for keeping it attached: reopening preserves context, and the context is worth money. "How's the laptop running since we got your photos back?" converts far better than "please rate your experience." The job history is the personalization.
Likely correct model: a follow-up encounter linked to the repair ticket, not a reopen of it — context preserved, without pretending the repair is unfinished. The confirmation it produces discharges the data custody and closes the ticket for real.
---
9. Branches
| branch | from | note |
|---|---|---|
| unrepairable | DIAG | the "recover your data + help you buy a replacement" path — a different product |
| declined | WFC | customer says no to the quote. Terminal, but the object still has to leave the building — a return path, possibly minus the deposit |
| abandoned | anywhere, mostly WFC | nobody ever returns. No clean terminal state existed. This is the eleven-month tub |
CatalogItemStatus terminal states are Sold | Donated | Trashed | Invalidated — no `Returned`, so "left the building still belonging to someone else" is unrepresentable and indistinguishable from loss.
---
10. Port target
Intent: port the system, not merely import the history. Most of osTicket's surface is assembly:
| osTicket | framework |
|---|---|
| tickets | domain-encounters |
| customers / agents | identity-parties + identity-auth + identity-rbac (sessions, permissions) |
| departments | ← the new library crate |
| canned responses | domain-notifications templates |
| email piping | infrastructure-communication |
| notes | content-notes |
| attachments | content-assets + AttachmentService |
| search | infrastructure-search |
| audit | foundation-audit-log (WORM) |
| SOPs | operations-runbook (content-only — no execution record, so "the tech actually did step 4" is uncaptured) |
The gap is the same one that was improvised around. The port must make first-class:
1. dept as a real state with guards 2. dept bound to a physical area 3. the tub as a multi-object custody unit 4. dwell per dept, ambient 5. the WFW trailing data-custody obligation
It is the difference between a ticket system a repair shop uses and a repair shop system.
10.0 PORTING CAUTION — this is an evolved system, do not tidy it
The model was not designed up front. It was tuned over years by trial and error. Every rule in it is likely a scar: payment-before-DIAG because someone diagnosed a machine and never got paid; tape on components because a screw went missing; random ticket numbers because a part went into the wrong tub; WFO split from WFP because "waiting" concealed a dropped order.
That knowledge is not visible in the design — only its consequences are. So the largest risk in the port is simplification:
- Treat any state that looks redundant or fussy as critical-path until proven otherwise
(Chesterton's fence). Find the failure it prevents before removing it.
- The ordering is tuned too. Guards sit where they do because placing them elsewhere caused a
specific problem. That ordering is non-obvious and is the first thing an outside designer would get wrong.
- Some fences have reasons only the operator remembers; some have reasons nobody remembers and the
process still carries correctly.
Simplify from evidence, not aesthetics. The historical transition counts (§10.1) will show which states actually did work and which were vestigial — that is the only sound basis for pruning.
10.1 The historical data is ground truth
Porting the osTicket data (MySQL → Postgres) yields:
- measured dwell budgets instead of invented ones — the real distribution of how long DIAG
actually took, what WFC's tail looked like, how often WFO stalled
- validation of this note's reconstructed vocabulary — where the data disagrees, the data wins
data/import has the shape (column mapping, validation rules, duplicate strategy, CsvParser) but is types-only with no persistence; the loader is unwritten.
Decide retention before loading, not after. Those tickets carry six-year-old customer PII for a business that no longer operates. The historical timings are what the design needs, and those survive de-identification intact.
---
11. What the framework must originate
After surveying all 256 crates, everything above is composition except:
1. The department as a first-class node owning area + court + options + responses + SOP + dwell budget. Nothing binds those six together; storage_locations.default_workflow is a lone hint that someone once considered it, and it has no users. 2. The bailment fence — is_sellable() takes no ownership input; Disposition has no ReturnToOwner; CatalogItemStatus has no Returned; Consent is user-keyed with no subject, so "I authorize destroying this drive" is unrepresentable — and a walk-in customer has no login to consent with. 3. `entered_state_at` projection + per-state dwell budgets. 4. A destruction certificate. compliance::DeletionRequest::complete() records no method, no witness, no certificate. 5. A random, uniqueness-checked, human-rendered identifier allocator (§5.2). 6. The multi-object custody kit (§3.3).
Everything else exists — several pieces in costume: legal-evidence is a general labeled-artifact custody kit; travel-compliance is a general certified-inspection-with-expiry kit; legal-matter::LegalHold is a general non-expiring lien.