Skip to content
Dallan
HomeAboutWorkResumeContact
HomeAboutWorkResumeContact Me
  • GitHubGitHub
  • LinkedInLinkedIn
  • Amity ResultsAmity Results
  • Email Dallan JonesEmail Dallan Jones
Dallan Jones /about

Places

Portrait of Dallan Jones

Dallan Jones

workflow automation · long-running pipelines · AI service integration

I turn manual, error-prone business processes into systems that run without anyone tending them. Most of that work has been enterprise platforms — real-time collaborative claims tools, payment integrations, and data pipelines that eliminated manual upkeep for teams across 2,000+ dealerships. Since 2024 it has also meant building my own products under Amity Results, including a SaaS media platform I own outright and built end to end. I care as much about what happens behind a system as what it shows you, because that's where the time savings actually live.

Read the case studies

Where I've worked

Founder & Full-Stack Developer — Amity Results

Jul 2024 – Current

Founded and built a SaaS media platform that transcribes, translates and subtitles instructional video — my own product, owned outright from architecture through deployment.

Full-Stack Developer — Fabled Solutions

Sep 2022 – Mar 2025

Extended a legacy F&I platform used by 2,000+ dealerships — real-time collaborative editing, a role-based claims dashboard, and an automated XML/SOAP sync pipeline.

Junior Full-Stack Developer — Fabled Solutions

Mar 2022 – Sep 2022

Built a U.S. Bank Virtual Card payment integration, external REST APIs for insurance partner onboarding, and a multi-dealership vehicle purchasing workflow.

Coding Bootcamp Mentor — Juno College

Oct 2021 – Apr 2022

Mentored students one-on-one through coursework, debugging and refactoring, and reviewed their projects for code quality and maintainability.

What I do, and what it changed

Four things I'm good at, each with the thing it actually changed for someone. Every one of these is written up in full.

Workflow automation

A safety instructor used to translate aloud over her own training videos, every session, in every class. She doesn't any more — the subtitles are burned into the video, and the cost stopped scaling with the number of classes.

Read how

Long-running asynchronous pipelines

Upload, transcode, transcribe, translate, encode — five stages, each minutes long, each able to die halfway with files already on disk. Built so a failure at any stage leaves no orphaned files, no partial rows, and a log entry you can diagnose from.

Read how

Authorization and real-time systems

Found a privilege escalation that let a contributor seize a subfolder and lock its owner out — by using the application, before any user hit it. Fixed the confusion underneath rather than patching the symptom.

Read how

Making legacy systems carry new weight

An enterprise F&I platform serving 2,000+ dealerships gained screen recording, AI transcription and searchable compliance records — and a claims dashboard that lost 600ms of page load along the way.

Read how
Building now

Amity Results

A media platform that transcribes, translates and burns subtitles into instructional video — speaker diarization, AI-generated chapters and summaries, transcript search, folder sharing, and subtitle, burned-in video and PDF export. Designed, built and deployed solo, and the only product here I own outright.

See it liveHow it was built
Contact /contact
Contact

Let's talk.

Whether it's a role, a contract, or just a technical problem you'd like a second opinion on — send a message and I'll get back to you directly.

Direct

dallanjones@pm.me

Elsewhere

Based in Edmonton, AB. Typically replies within a day or two.

Résumé resume.pdf

Your browser won't display PDFs inline.

Download the résumé
Terminal ~

Desktop ready. Terminal available.

Teaching Without Translating /work
Jump to section
  • Overview
  • Engineering Challenge
  • Engineering Initiatives
  • What the video shows, in text
  • The Thirteen-Day Stopgap
  • Media Ingestion: Failing Safely at Every Stage
  • Transcription & AI Enrichment
  • Subtitles, Translation & Export
  • Sharing as an Authorization Model
  • Architecture
  • Results
  • Engineering Practice
  • On AI Pairing
  • What I’d Do Differently
  • Scope
  • Backend
  • Frontend
  • Infrastructure
Project Details

Technologies

Laravel 12Laravel OctaneVue 3InertiaPiniaTailwindMySQLLaravel ReverbFFmpegAssemblyAIGoogle Cloud TranslationLLM Gateway

Engineering Highlights

  • 724-line emergency pipeline, shipped in thirteen days
  • Queued, resumable media ingestion pipeline
  • LLM chapters that never invent a timestamp
  • Race-safe storage quotas via row-level locking
  • Webhook completion with five-minute reconciliation sweep
  • Speaker diarization with user-assigned names
  • Subtitle, burned-in video and PDF export
  • Privilege escalation found and closed

Teaching Without Translating

Role Founder / Full Stack DeveloperIndustry Workplace Safety TrainingUsers Instructors & Training Teams

Overview

A workplace safety instructor teaches occupational health and safety to factory workers — chemical handling, incident response, the correct movement for a procedure. Her material is visual. Her students are Vietnamese speakers. Her videos were in English.

Her requirement, in her own words:

Translation is necessary to avoid the need for interpreters. Teachers don’t need to speak while the video is playing.

A workplace safety instructor addressing factory workers in a training room, her slides projected on the screen behind her.

That is not a request for subtitles. It is a request to stop doing two jobs at once.

Every session, she was translating aloud over her own video — speaking Vietnamese across the English audio, splitting her attention between delivering the lesson and interpreting it. Her students got a lesson competing with a translation. She got neither job done as well as she could do either one alone. Subtitles don’t make that easier; they remove it, permanently, from every session she will ever teach, at a cost that stops scaling with the number of classes. Which is the shape of every automation problem worth solving.

I had been building a media platform since mid-2024 that would eventually do this. It was not ready. Her course was scheduled.

So there are two builds here: a 724-line PHP pipeline written in thirteen days that got her videos subtitled in time, and the platform that turns the same capability into something she operates herself. Expand any section below for the full technical breakdown — problem framing, implementation detail, and the reasoning behind each trade-off.

Engineering Challenge

Video processing was the easy part. The hard parts:

  • Burn-in, not sidecar subtitles. The video plays in a room, on that room’s equipment, opened by whatever will open it. A subtitle track that requires a compatible player — and someone enabling it correctly, in front of a class — is a track that fails in front of a class. The pixels had to carry the text.
  • Every long operation is a place to fail. Upload, transcode, transcribe, translate, encode. Each one can run for minutes, involve an external service, and die halfway with files already written to disk.
  • Authorization, the moment there was more than one user. Sharing looked like a CRUD feature and was an access-control model. Those don’t throw errors when they’re wrong. They silently permit.

Engineering Initiatives

Your browser can't play this video. Download it instead.

Thirty-eight seconds of the platform, silent and unnarrated. The five sections below are how each part of it works.

What the video shows, in text

The clip has no audio, so this is its text alternative rather than a caption track.

  1. A media file — q3-planning-call.mp4 — open in the library with its duration, resolution and tags.
  2. Transcript. Speaker-labelled utterances with timestamps. Marcus and Dana discuss a migration window; one block highlights as playback reaches it.
  3. Subtitle generation. A language is typed into the picker — Vietnamese — and generation runs.
  4. Subtitles. English and Vietnamese both listed as Ready, each offering Download and Export video.
  5. Playback. The video plays with subtitles burned into the picture rather than layered over it.
  6. Summary. A generated summary, a chapter list with real timestamps, and generated topics and tags.
  7. Search. A term is typed and the transcript filters to matching blocks, each with its timestamp.

The Thirteen-Day Stopgap

2024, then run on request until the platform replaced it · PHP 8.3 · AssemblyAI · Google Cloud Translation · FFmpeg

Wrote a standalone CLI pipeline rather than rush two years of platform work to meet a client's course date — then ported its logic into the platform once it existed.

Source is public under MIT: github.com/dallanj/language-barrier.

Problem

The client needed subtitled video for a scheduled course. The platform was mid-build and nowhere near able to serve her. Two obvious options were both bad: rush the platform into a state that met her deadline and live with what that cost the architecture, or tell her the tool wasn’t ready.

Her problem was narrow, well-defined, and completely separable from the platform — one person, one machine, files in and files out. It needed no auth, no database, no UI, none of the things that make the platform take two years.

Solution

A CLI pipeline operating entirely on local files:

  1. Upload to AssemblyAI, transcribe with automatic language detection
  2. Poll the job asynchronously to completion
  3. Fetch the transcript as WebVTT
  4. Parse the cues, translate through Google Cloud Translation, rebuild the VTT
  5. Burn the subtitles into the video with FFmpeg — one output per target language

Technical Highlights

  • WebVTT as the interchange format, adopted immediately rather than at the end. The alternative was passing transcript JSON between translation and burn-in and converting last. Standardizing on VTT the moment a transcript arrives keeps every intermediate state human-readable — I could open a file mid-pipeline and see whether a translation was sane — and FFmpeg’s subtitles filter consumes VTT directly, so nothing needs converting before burn-in.
  • Every stage independently re-runnable. One function per stage, each output feeding the next. The script prompts for an existing transcript ID so transcription can be skipped entirely, and translation checks for its own output before spending API quota. Not tidiness: transcription costs money and minutes, and the first several attempts at subtitle styling were wrong. Re-running only the burn-in stage is the difference between iterating on legibility in minutes and in hours.
  • Translation batched in chunks of 128 cues rather than one request per cue — inside request-size and rate limits, with per-cue timestamp alignment intact.
  • Live progress on both long operations. A CLI tool that goes silent for eleven minutes on a long video is a tool the operator kills.

Engineering Considerations

The difficult parts were not the API integrations.

Subtitle legibility took real trial and error. Burned-in text is permanent — there is no “close enough” to pass through. Font, outline and positioning all had to survive text sitting over a bright factory floor with white PPE in frame.

FFmpeg’s non-blocking stderr pipe had to be read for progress without hanging the process or pinning a core in a tight loop, resolved with a usleep(100000) interval.

Escaping paths for the subtitles= filter is a separate escaping layer from shell escaping — colons and backslashes are meaningful inside FFmpeg’s filter syntax. Getting one layer right and the other wrong produces an error that points at neither.

Outcome

  • Delivered Vietnamese-subtitled video in time for the course. The instructor taught it without translating aloud over her own material for the first time.
  • Kept the platform build uncompromised by a deadline that wasn’t the platform’s.
  • Published under MIT and left standing rather than folded away — an honest artifact of what solving the problem urgently looked like.
  • Its logic is now in production inside the platform. SubtitleBurnService carries a docblock naming the script as its origin: identical libx264/AAC re-encode and identical subtitle styling, with proc_open()/shell_exec() replaced by Symfony Process, which handles argument escaping itself and exposes a progress callback without the manual non-blocking pipe reads the script needed.

Media Ingestion: Failing Safely at Every Stage

Laravel Pipelines · Queues · FFmpeg · MySQL row locking

A staged upload pipeline where the routing decision, the type column, and the processor cannot disagree — with race-safe quotas, weighted progress, and cleanup for files a database rollback can't touch.

Problem

An upload is not one operation. It is: receive bytes, decide what kind of file this is, check whether the user is allowed to store it, probe it, transcode it, generate a thumbnail, a preview clip and an animated preview, persist several related rows, then hand off to transcription.

Any of those can fail. Several of them write files to disk before the failure. A database transaction rolls back rows; it does not roll back an 80MB preview clip that FFmpeg already wrote.

Three specific failures made this concrete:

  • A QuickTime voice recording is video/quicktime with no video track. The pipeline was chosen from the MIME prefix, so it was bound to the video processor before anything inspected the file. Downstream, ->videos()->first() returned null and ->get() on it produced Call to a member function get() on null — a message describing the symptom and naming nothing.
  • Photos never uploaded at all. The type column normalized image to photo; the pipeline selector didn’t, so getPipelines() fell through to its default and threw Unsupported media type: image for every image.
  • Two concurrent uploads could both pass a storage quota check and both commit, because request-time validation reads a total that the other upload hasn’t written yet.

Solution

One resolver, consulted once. MediaTypeResolver is the single source of truth for which pipeline a file belongs to. It probes with php-ffmpeg — the same library the pipelines themselves use — so the routing decision and the processing agree by construction rather than by coincidence. Crucially it treats “tagged as video” and “carries a picture” as different questions: a track can be marked video and hold no image, which is exactly what QuickTime timecode and metadata tracks do.

return $this->hasPicture($storedPath) ? self::VIDEO : self::AUDIO;

MediaService resolves the type once and passes it into the pipeline as data. CreateMediaModel is explicitly forbidden from re-deriving it.

Quota checked under a row lock, inside the transaction. CheckStorageQuota runs first and takes lockForUpdate() on the user row, using it as a mutex for the transaction’s duration. A second concurrent upload for the same user blocks there until this one commits or rolls back — so by the time it re-reads used bytes, it sees this upload’s committed bytes. Request-time validation stays in place as fast feedback; it simply cannot close that window on its own.

Progress driven by named stages, not hand-guessed percentages. Stage weights live in config/processing_stages.php per job type and subtype, and pipeline stages call advanceStage('transcoding') rather than setting a number. Progress is the cumulative weight up to that stage, capped at 99 — only markCompleted() is allowed to say 100.

Technical Highlights

  • tries = 1 on the upload job, deliberately, with the reason in the code. An uploaded file isn’t safely retryable without re-sending the bytes. Because that means a failure is final, the job cleans up its own stored file in every catch branch — otherwise every failure would leave the raw upload in uploads/pending forever.
  • Quota rejection is logged at info, not error. Exceeding your plan’s limit is an expected, user-facing outcome, not a defect. Logging it as an error trains you to ignore errors.
  • Failure logs record origin and a trimmed trace, because the message alone wasn’t diagnosable. "Call to a member function get() on null" names neither the probe that returned null nor the stage it happened in. Failures now capture the exception class, the base-path-relative file and line, and the first six frames — enough to diagnose from the log instead of by reproduction.
  • The outer try/catch inside ProcessVideo was removed on purpose. Swallowing the error there produced a log line plus a confusing null-object failure further downstream. Letting it propagate means the transaction rolls back and the job records the real message.
  • Files written before a failure are tracked explicitly. Pipeline stages call trackGeneratedFile() after each write. On success it’s informational; on failure it’s the list uploads:cleanup uses to delete every artifact the pipeline produced — precisely the files a DB rollback never touches.
  • Cleanup runs in three passes. Files from failed jobs (a backstop for the inline cleanup, in case the job was killed before reaching its catch block), orphaned files no ProcessingJob references at all (crashes before the job row existed), and empty directories. Orphans younger than two hours are left alone so the sweep can’t race an upload that is mid-flight. --dry-run reports without deleting.
  • One event, many consumers. markCompleted() fires a generic ProcessingJobCompleted, and listeners decide what happens next per job type. That is the extension point that dispatches transcription after an upload finishes, rather than the upload job knowing about transcription.

Engineering Considerations

The listener that chains upload into transcription originally checked for video alone. Once audio files were being classified correctly rather than mis-routed, that check became the bug: a plain MP3, or a QuickTime voice recording now properly identified as audio, was stored and then silently never transcribed. No error, no failed job — a file that simply never gained a transcript.

Fixing the classifier is what surfaced it. That is the ordinary shape of this kind of work: correcting one thing exposes the code downstream that was quietly relying on the original mistake.

Outcome

  • Routing, the type column, and the chosen processor became a single decision that cannot drift.
  • Closed the concurrent-upload quota race that request-time validation structurally cannot catch.
  • Made failures diagnosable from logs — origin, stage, and real message rather than a downstream null.
  • No orphaned media on disk after a failure, including files written before the failing step.
  • Progress reporting that reflects actual stage weights instead of invented percentages.

Transcription & AI Enrichment

AssemblyAI · LLM Gateway · Webhooks · Scheduled reconciliation

Speaker-diarized transcription with LLM-generated chapters and metadata — built so the model never invents a timestamp, and so a webhook that never arrives can't strand a job.

Problem

Transcription is a long-running external operation. You submit a file, and minutes later the provider calls you back. Three things follow from that:

  • The webhook is not guaranteed. It can be missed, dropped, delivered while the app is deploying, or never sent. A pipeline that treats the callback as its only completion path has jobs that hang forever.
  • Not every file the user uploads is a file the API accepts. AssemblyAI’s supported container list doesn’t include QuickTime .qt, so a screen or voice recording in that container is rejected at upload.
  • A raw transcript is not a product. Wall-to-wall text with no structure is barely more useful than the video. It needs chapters, a summary, searchable metadata, and word-level timings for playback.

Solution

Prepare files for the API without degrading them. AssemblyAI’s own guidance is to submit audio in its native format, because re-encoding costs quality and they downsample to 16kHz mono internally regardless. AudioTranscodeService therefore makes a three-way decision rather than converting everything to MP3:

Case Action
Extension already supported Returned untouched. FFmpeg never runs.
Unsupported container, codec has a supported one Remuxed with -c:a copy — no decode, no generational loss, finishes in about the time it takes to read the file
Anything else FLAC. Lossless, and far smaller to upload than WAV

The original upload is never modified; derived files land in a separate directory so the user’s file stays exactly as they sent it.

Assume the webhook won’t come. ProcessTranscription submits the job and ends — the webhook takes it from there. transcriptions:sweep runs every five minutes with withoutOverlapping(), finds transcript jobs untouched for five minutes, and asks AssemblyAI directly what their status is. Completed ones get dispatched onward, errored ones fail, in-progress ones reset the clock. Anything still open after two hours is given up on permanently, whatever the API says at that point.

Let the model group, not measure. Chapters are generated through AssemblyAI’s LLM Gateway, replacing their deprecated auto_chapters flag. The prompt hands the model numbered paragraphs and asks for paragraph index ranges — never timestamps:

/*
 * Deliberately does NOT ask the model to invent start/end millisecond
 * timestamps itself — LLMs are unreliable at producing exact numbers
 * for data they weren't given precisely. Instead it hands back
 * paragraph indices, and resolveTimestamps() below maps those back to
 * AssemblyAI's own real timestamps.
 */

The model does what it’s good at (deciding where a topic changes) and is structurally prevented from doing what it’s bad at (producing exact numbers). Chapter timings are therefore exact by construction, not by luck.

Technical Highlights

  • Output shape matched to the thing it replaced. The generated chapters return {gist, headline, summary, start, end} — the same shape AssemblyAI’s deprecated auto_chapters returned — so swapping the implementation required no frontend change at all.
  • Model output is parsed defensively. The response is stripped of stray markdown fences before decoding, and a chapter referencing an out-of-range paragraph index is skipped with a warning rather than crashing the job. You do not trust a model to honour a format instruction every time.
  • Enrichment is layered so cheap inputs feed expensive ones. Metadata generation (title, description, tags, topics, keywords) is fed the chapter summaries — already condensed, cheap to re-send — and only falls back to raw transcript text if chapter generation produced nothing.
  • Every enrichment step is independently failable. Transcript blocks, chapters and metadata each sit in their own try/catch, and whatever succeeded is saved. A failure in chapter generation costs you chapters; it does not cost you the transcript.
  • Word-level blocks built for playback, not for reading. Sentences are regrouped into blocks of roughly eight words, preferring to break at punctuation once that target is reached and hard-capping at eleven. Each block keeps every word’s individual start/end in milliseconds, which is what makes clickable timestamps and word-by-word highlighting possible.
  • The LLM client is a sibling of the transcription client, not a special case. LLMGatewayClient mirrors TranscriptsClient in structure but targets a different host, because LLM Gateway is a separate service that happens to share billing and auth with the rest of AssemblyAI.
  • Speaker diarization with user-assigned names. Utterances carry speaker labels; users map those labels to real names, and exports resolve them at render time rather than rewriting the stored transcript.

Engineering Considerations

The reconciliation sweep is the same pattern I built into a claims platform at an agency, where a locking system depended on a third-party WebSocket service and a payment pipeline depended on a bank’s webhooks. In both cases the answer was identical: do not trust a single event to have fired correctly — verify independently on a schedule.

It is worth being precise about why the sweep isn’t redundant with the webhook. They fail differently. The webhook fails by not arriving; the sweep fails by being slow. Running both means the common case is fast and the failure case is bounded — five minutes to notice, two hours to give up — rather than unbounded.

Outcome

  • Files the API would have rejected are accepted, without re-encoding anything that didn’t need it.
  • A missed webhook costs five minutes, not a permanently stranded job.
  • Chapter timestamps are exact because the model was never allowed near them.
  • A partial enrichment failure degrades the transcript rather than losing it.
  • Word-level timings power clickable transcript navigation and synchronized highlighting during playback.

Subtitles, Translation & Export

Strategy pattern · Google Cloud Translation · FFmpeg · dompdf

The original script's transcribe-translate-burn logic rebuilt as job-deduplicated, resumable work with two generator strategies and three export formats.

Problem

The script did this for one person on one machine. As a product it has to handle several users asking for several languages on the same media, potentially at the same time, where each request is minutes of API calls and CPU-bound encoding — and where asking twice should not cost twice.

Solution

Two generators behind one interface. GenerateSubtitleJob picks a strategy by comparing the requested language against the transcript’s own:

  • NativeSubtitleGenerator — fetch the VTT AssemblyAI already produced, store it. No translation, no cost.
  • TranslatedSubtitleGenerator — take the native subtitle (reusing it if it exists, fetching it if not), parse the cues, translate in chunks of 128, rebuild the VTT with original timings and translated text.

The translated generator’s reuse of the native file is the script’s idempotency idea, kept: don’t pay for work already done.

Deduplication at dispatch. SubtitleGenerationPipeline::dispatchFor() looks for an existing queued or processing job for the same media and language before creating one, and returns the existing job if it finds it. Two users asking for Vietnamese on the same video get one job and both watch it.

Timing survives translation, because timing is never translated. VttParser extracts cues; only the text array goes to the translation API; VttBuilder reassembles using the original timestamps with translated text slotted in by index, falling back to the source text where a translation is missing. Cue timing cannot drift because it never leaves the application.

Technical Highlights

  • The VTT parser handles input the spec permits but tools produce inconsistently. Optional hours, either . or , as the decimal separator (SRT-style commas normalized to VTT dots), trailing cue settings like align: and position: ignored, bare numeric cue identifiers skipped, and timestamps padded to HH:MM:SS.mmm when hours were omitted.
  • Burn-in ported from the script with the mechanics upgraded. Same libx264/AAC re-encode and same subtitle styling; proc_open() replaced by Symfony Process, which handles argument escaping and exposes a progress callback natively. The FFmpeg filter-syntax escaping is still there and still commented, because it is still a separate concern from shell escaping.
  • Encode progress parsed from FFmpeg’s stderr into a real percentage. Duration is probed with ffprobe first; the process callback matches time=HH:MM:SS.ms on each chunk and updates the job’s progress. The user watching a nine-minute encode sees it move.
  • WebM inputs are re-wrapped to MP4 on export, because burning subtitles into WebM through this filter chain isn’t reliable — the same fix the script needed, carried across.
  • Timeouts matched to the work. The burn job runs with tries = 2, backoff = 30, timeout = 3600, with a comment explaining the difference: video encodes are CPU-bound local work, unlike the transcription jobs which are just waiting on an external API.
  • PDF export by dompdf, chosen after Browsershot failed. Headless Chrome’s crashpad handler wouldn’t launch in this Docker environment regardless of HOME overrides or disable flags — a well-documented, unresolved issue. dompdf is pure PHP: no Node, no browser binary, no Docker changes, nothing to break on an unrelated Chrome update. The trade-off is a weaker CSS engine, so the template is written defensively with tables and floats instead of flexbox.
  • Two PDF layouts, same words. Grouped by time, or grouped by speaker — with an automatic fall back to time-grouping when the transcript has no diarization to group by.
  • Language list cached in two layers. A static property memoizes within the request, and Cache::flexible keeps it warm across requests with a six-hour fresh window and a seven-day stale window, so a slow upstream call never blocks a page render.

Outcome

  • Requesting a language you already have costs nothing; requesting one twice runs once.
  • Subtitle timing is exact after translation because timings never leave the application.
  • Long encodes report real progress instead of appearing hung.
  • Three export paths — subtitle file, burned-in video, PDF transcript — from one transcript.

Sharing as an Authorization Model

12 merged PRs · Laravel Policies · Pipeline stages · Reverb

Folder sharing with three roles, consent-based invites and ownership transfer — and a privilege escalation that let a contributor seize a subfolder and lock its owner out.

Problem

The moment media is shared, the product stops being a media tool and becomes an access-control system.

Sharing looks like CRUD: invite a user, store a row, let them in. It isn’t. Authorization failures don’t announce themselves — a missing condition on a list query returns a page that renders correctly and shows the wrong rows to the wrong person. Nothing turns red. The only way to find these is to use the running application and notice something out of place.

Twelve merged pull requests, three of which are bugfix/ branches against work from earlier in the same sequence:

#165  feature/164-folder-invites-schema      #181  feature/180-permission-sync-and-redirects
#167  feature/166-close-visitor-permission-gap  #182  bugfix/177-revoke-ownership-leaks
#169  feature/168-invite-and-revoke-endpoints   #184  feature/183-ownership-transfer-and-leaving
#171  feature/170-invitation-emails             #187  bugfix/185-user-route-name-collision
#174  feature/173-shared-with-me                #188  feature/186-sharing-changelog
#176  feature/175-broadcasting-invitation
#179  bugfix/178-login-log

Those bugfix branches are left visible on purpose. The defects were found by using the application, not by a later audit.

The escalation

Severity: highest. A user invited to contribute to a folder could take a private space inside it and remove the folder’s owner from that space entirely.

Reproduction — A owns parent and invites B as a contributor; B creates child inside it, then revokes A:

child.user_id=B? yes
B pivot role on child: contributor
B can INVITE on child (owner-only ability): YES     ← escalation

BEFORE: A can view child: yes
AFTER : A can view child: NO
AFTER : A can delete child: NO
AFTER : A still owns parent: yes                    ← A owns the parent
AFTER : child still inside parent: yes              ← and it is still in it
AFTER : B can view child: yes

Read the last three lines together. A owns the parent. The child is still inside it. A cannot see it.

How it was found: not by auditing the policy. A subfolder appeared under “Shared with me” where it had no business being — the subtree-collapsing query excluded children whose parent was also shared, but not children whose parent was owned. The visible bug looked cosmetic. Pulling that thread produced this.

Two compounding causes. FolderService::getContributors() was written assuming the parent’s owner owns a nested folder, while CreateFolder set user_id to the creator — two pieces of code holding different beliefs about one column. And FolderPolicy::isOwnerOrContributor() short-circuited on $folder->user_id === $user->id and returned before the $roles filter was considered, so being recorded as the creator granted every ability the policy could grant, invite included. Either alone is a bug. Together they are an escalation path.

The fix was to stop one column carrying two facts:

Fact Where it lives What it grants
Ownership folders.user_id Administrative authority
Authorship folders.created_by_user_id (new) Writer-level only, never administrative
Membership pivot role Whatever the invite specified

That distinction is what makes “a contributor may delete what they created, and nothing else” a sentence the code can express. Before the split it wasn’t expressible — no column meant “made it but doesn’t own it.”

child owner is A: yes
B can invite/revoke on child: no
after B attempts revoke -- A can view child: yes
after B attempts revoke -- A can delete child: yes

Test: FolderOwnershipTest::it does not let a contributor manage access to a subfolder they created. Reverting the single line that sets subfolder ownership fails three tests, this one among them.

Two rules where there should have been one

A separate defect, same root shape: a folder nobody could delete. The UI offered Delete because the policy allowed it; the API refused because the pipeline didn’t.

A can delete ZSub (policy): NO      ← A had removed themselves; no pivot row
B can delete ZSub (policy): yes     ← user_id short-circuit
B pivot role on ZSub: contributor   ← but CanDestroy required Owner
B attempted delete -> still there
A attempted delete -> still there

FolderPolicy::delete() and the CanDestroy pipeline stage each carried their own copy of the rule, and the copies had drifted. HasPermission had the same shape. A folder satisfying one and failing the other was permanently stranded — not a permissions error a user could act on, a dead object in their library.

The pipeline stages now delegate to the policy. One rule, one place, consulted by everything.

This is usually filed as a tidiness problem. It isn’t. Two rules that agree in every case you tested are one refactor away from disagreeing, and when authorization rules disagree the failure isn’t an exception — it’s an object your user can see and cannot touch.

Design decisions

  • Invites require consent, even from people who already have accounts. Originally, inviting an existing user attached them to the folder immediately — faster, no email round-trip. Under that behaviour, anyone who knew your email could push a folder into your library with a name and description they controlled. That is a message channel, and message channels get abused. The fix removed a code path rather than adding one.
  • Contributors cannot share. Sharing is not itself shareable. Someone who needs to bring others in must be given ownership — which is why transfer exists. This closes the class of problem where access spreads through a graph nobody is watching.
  • Transfer re-parents and cascades. A folder lives in its owner’s tree, so handing it over moves it into the recipient’s library and carries every descendant. Leaving descendants behind would recreate the split-ownership bug one level down. Nested folders cannot be transferred at all — that would carve a hole in the current owner’s structure.
  • An owner cannot leave a shared folder. They transfer it or unshare it. This guarantees every folder has exactly one owner who can reach it — the invariant preventing orphans, and a deliberate refusal to build a button users will occasionally look for.
  • The changelog logs from services, not broadcast events. Reusing the events is tempting since they already fire on every access change, but they cannot do audit: leave() and revokeContributor() emit the identical event, and inviting an address with no account emits nothing at all. Keeping open tabs fresh is a different job from recording what happened.
  • Notifications split actionable from informational, under one invariant. A badge once counted items nothing could display: invite notifications whose invites had been answered were filtered out of the actionable list, excluded from the informational feed, still counted unread, and skipped by both bulk-clear controls — four behaviours, each correct alone. Replaced by a single rule: actionable + unread means a decision is live; actionable + read means resolved. That also closed a slow leak, since the prune had been sparing all actionable rows and resolved ones would have accumulated forever.

Outcome

  • Closed a privilege escalation before any user encountered it, and made the underlying confusion structurally impossible rather than patched.
  • Eliminated the “two authorization paths disagree” class of defect by making the policy the single source.
  • Made delegated write access safe on nested structures — the case every sharing model gets wrong first.
  • 75 of the platform’s 129 tests cover sharing, permissions, invites and notifications.

Architecture

Laravel 12 with Octane, Sanctum for authentication, Reverb for broadcasting. Vue 3 with Inertia and Pinia, Tailwind, MySQL, Sail for local parity. AssemblyAI for transcription, diarization and LLM enrichment; Google Cloud Translation; FFmpeg for all media work.

Long work runs as ProcessingJob rows — a polymorphic subject, a type, a stage list, and a weighted progress value — with every status transition broadcast on a per-user private channel so a browser tab shows real progress on work happening in a queue worker it has no connection to.

Two patterns recur across everything above.

Never trust a single event to have fired correctly. The transcription webhook has a five-minute reconciliation sweep behind it. Inline cleanup after a failed upload has a nightly sweep behind it. This is the same pattern I built into an enterprise claims platform, where claim locking depended on a third-party WebSocket service and payments depended on a bank’s webhooks — both needed a scheduled job that independently re-derived the truth. The events make the common case fast; the sweeps make the failure case bounded.

Every serious defect came from one fact living in two places. Ownership and authorship shared a column. The deletion rule lived in both the policy and the pipeline. The media type was derived independently by the router and the model. A notification’s meaning was approximated by four call sites. Storage-usage math was written once in the limits service and again in the dashboard loader. In each case both representations were individually defensible, and the failure only appeared where they disagreed.

The corresponding pattern in the fixes is that most of them removed a path rather than adding one. The pipeline delegates to the policy instead of carrying its own rule. Four notification behaviours collapsed into one invariant. MediaTypeResolver replaced two derivations with one probe. The consent change deleted the immediate-attach branch entirely — leaving one flow instead of two, and the one deleted was the dangerous one.

Adding a guard is the reflex. Finding the duplicated fact underneath and deleting one of it is the actual fix.

Results

  • Removed a recurring labour cost — live translation in every training session — from a business process, first with a script and then with a product.
  • The instructor now delivers Vietnamese-language safety training without translating during playback. Comprehension on the floor improved, and she has since moved into a government training role.
  • Delivered under a client deadline without compromising a two-year architecture to meet it.
  • Built an ingestion pipeline where a failure at any stage leaves no orphaned files, no partial rows, and a diagnosable log entry.
  • Integrated LLM enrichment in a way that structurally prevents the model from producing the kind of output it is unreliable at.
  • Closed a privilege escalation, a permanent-stranding defect, a concurrent-quota race, and an unbounded table growth path.
  • 608 commits, June 2024 to present. 129 tests, 339 assertions. Solo across the full lifecycle.

Engineering Practice

Regression tests are verified by reverting the fix. A test that passes both before and after proves nothing. For the Visitor permission gap I reverted the fix and re-ran:

FAILED  it does not let a visitor modify a shared folder
Failed asserting that true is false.
  ➜ expect($visitor->can('update', $folder))->toBeFalse();

Now the test is known to be load-bearing.

Comments explain why, and name the failure they prevent. The tries = 1 on the upload job says uploaded file isn’t safely retryable without re-sending bytes. The chapter prompt says why the model is never asked for a timestamp. The PDF service records that Browsershot was tried and why it was abandoned. This is written for whoever simplifies it in a year — probably me.

The server is authoritative; the client is told, never trusted. FolderResource reports each folder’s abilities so the UI can’t offer an action the API will refuse — a UX concern. The API refuses regardless of what the UI displays.

Known duplication is documented in place rather than left to be rediscovered. UserLimitsService::usedBytes() carries a note that the same join logic still lives in the dashboard loader and should be collapsed into it. Writing that down is how it stays on the list.

On AI Pairing

The sharing subsystem was built in an intensive AI-paired session. The architecture, the permission model and every product decision above were mine; the implementation was paired.

I state that plainly because the record supports it. The privilege escalation was found by me, using the running application, after noticing a subfolder in the wrong list — as were several other defects here, with exact reproduction steps. The decision that a contributor cannot share, that an owner cannot leave, that ownership and authorship are different facts, that an LLM is never handed a timestamp to invent: those are lines I set and held against the convenient answer.

Directing a paired build, catching what it gets wrong, and refusing the model’s easy path is the skill that matters now. This history demonstrates it, and it is checkable.

What I’d Do Differently

  • Separate ownership from authorship from the start. They look like one fact until a contributor creates something inside your folder. I paid for that in a privilege escalation. Any model with nesting and delegated write access needs the split on day one.
  • Resolve derived facts once, at the boundary. Media type, deletion authority, storage totals — every one of these bit me by being computed in two places. The pattern was visible after the second occurrence and I kept finding new instances.
  • Never let authorization logic exist in two places. The policy and the pipeline each seemed like a reasonable home for the rule. Having both was the bug.
  • route:cache belongs in CI on day one, along with anything else that can only fail at deploy time. A duplicate route name is tolerated at runtime and throws on cache — so the failure waits for the deploy.
  • Test the roles nobody uses yet. The Visitor permission gap was latent purely because nobody had been a Visitor. An unused role is untested code with a permission attached.

Scope

Backend

  • Staged media ingestion pipeline with race-safe storage quotas
  • FFmpeg transcoding, thumbnails, animated previews, preview clips
  • AssemblyAI transcription with speaker diarization
  • Container-aware audio preparation (remux-or-FLAC, never blind re-encode)
  • Webhook completion plus scheduled reconciliation sweep
  • LLM chapter and metadata generation via AssemblyAI LLM Gateway
  • Subtitle generation with native and translated strategies
  • Google Cloud Translation with batched cues
  • Subtitle burn-in export, PDF transcript export
  • Folder sharing, invitations, ownership transfer, audit changelog
  • Policy-based authorization with pipeline delegation
  • Plan and storage-limit enforcement
  • Self-healing cleanup for failed and orphaned uploads

Frontend

  • Vue 3 + Inertia application shell, Pinia state
  • Media library, folder tree, sharing and permission management
  • Transcript viewer with word-level highlighting and clickable timestamps
  • Live processing-queue widget driven by broadcast job progress
  • Real-time notification surfaces

Infrastructure

  • Laravel Octane, MySQL, Laravel Reverb, Laravel Sail
  • Queue workers for all long-running media work
  • Scheduled reconciliation: transcriptions:sweep (5 min), uploads:cleanup and notifications:prune (nightly)

language-barrier is public under MIT at github.com/dallanj/language-barrier. The platform is private; the reproductions, tests and commit history described here can be walked through on request.

PreviousScreen Recording Audit SolutionNextModernizing an Insurance Platform

Technologies

Laravel 12Laravel OctaneVue 3InertiaPiniaTailwindMySQLLaravel ReverbFFmpegAssemblyAIGoogle Cloud TranslationLLM Gateway

Engineering Highlights

  • 724-line emergency pipeline, shipped in thirteen days
  • Queued, resumable media ingestion pipeline
  • LLM chapters that never invent a timestamp
  • Race-safe storage quotas via row-level locking
  • Webhook completion with five-minute reconciliation sweep
  • Speaker diarization with user-assigned names
  • Subtitle, burned-in video and PDF export
  • Privilege escalation found and closed
On this page
  • Overview
    • Engineering Challenge
  • Engineering Initiatives
    • What the video shows, in text
    • The Thirteen-Day Stopgap
      • Problem
      • Solution
      • Technical Highlights
      • Engineering Considerations
      • Outcome
    • Media Ingestion: Failing Safely at Every Stage
      • Problem
      • Solution
      • Technical Highlights
      • Engineering Considerations
      • Outcome
    • Transcription & AI Enrichment
      • Problem
      • Solution
      • Technical Highlights
      • Engineering Considerations
      • Outcome
    • Subtitles, Translation & Export
      • Problem
      • Solution
      • Technical Highlights
      • Outcome
    • Sharing as an Authorization Model
      • Problem
      • The escalation
      • Two rules where there should have been one
      • Design decisions
      • Outcome
  • Architecture
  • Results
  • Engineering Practice
  • On AI Pairing
  • What I’d Do Differently
  • Scope
    • Backend
    • Frontend
    • Infrastructure

All work

Case studies

  • Teaching Without TranslatingA media platform that transcribes, translates, and burns subtitles into instructional video — built so a safety instructor could stop translating aloud over her own lessons, after a thirteen-day emergency script proved the problem was worth solving properly.Laravel 12Laravel OctaneVue 3
  • Modernizing an Insurance PlatformAn enterprise claims management platform designed to streamline claim processing, improve collaboration, and automate complex business workflows.LaravelVue.jsMySQL
  • Screen Recording Audit SolutionAn enterprise compliance and AI-powered auditing platform for automotive F&I presentations, featuring browser-based recording, automated transcription, searchable subtitles, and PDF audit reporting.LaravelPHPVue 2

Experiments

Built for their own sake. No write-ups.

  • Desktop Environment Portfolio2023A browser window manager — drag, resize, snap, minimise to dock with a genie animation. Never shipped: I bolted an admin backend onto a portfolio, which is a lesson rather than a feature. Its window logic runs this site.Vue 3PiniaLaravel
  • 2D RPG Engine2023Quest, inventory, dialog and NPC systems built from scratch, because the interesting part of a game is the systems underneath it.GameMaker Studio 2GML
  • Encrypted Chat Platform2022Direct and group chat with Pretty Good Privacy encryption — written before I had any business writing crypto, which was the point.PHPMySQLJavaScriptjQuery

Nothing matches that.