# PRD — ear_lambdas

Evidence date: 2026-10-16.

Source: `git@bitbucket.org:overdrive/ear_lambdas.git`, `master` at `1539c1d` (2023-09-27, Ettiene, "added on put function").

There is no prior PRD in the source repository or in Silicon Ark. Requirements below are recovered from the Lambda console export (SAM yaml plus the zip beside it). Provenance is labelled.

Database passwords are present in five yaml files. They are not repeated in this document.

The Webpack bundles for `create`, `sort_files`, and `transcode` were built from `C:/wamp64/www/theear_transcode/region_south_africa/`. The handler logic matches the behaviour recorded in `silicon_ark/theear_transcode/` for those three files. This PRD describes the exported bytes. It does not claim a byte-for-byte match to `theear_transcode` commit `fda49df`.

## Product purpose

**Implemented.** On a daily EventBridge cron, a Lambda HTTP GETs a fixed list of Ear Academy admin URLs.

**Implemented.** On an hourly EventBridge rate, a second Lambda HTTP GETs three Ear Academy admin URLs.

**Implemented.** Three further Lambdas, named `phpCronEveryDay2am`, `phpCronEveryDay6am`, and `phpCronEveryDay8am`, HTTP GET smaller lists of the same site. Their templates contain no schedule.

**Implemented.** When an S3 object-created event is delivered to `transcode.main`, the function inserts a MySQL `resources` row for a known file suffix, using bucket name `the-ear-prod-resources` in the stored URL.

**Implemented.** `GET /sort_files` lists `the-ear-staging-resources` and inserts a `resources` row when `name` and `type` are not already present.

**Implemented.** `POST /create` writes a DynamoDB item shaped like the Serverless Stack notes example.

**Implemented.** A function copies one S3 key from `the-ear-prod-resources` to `the-ear-prod-video-upload`.

**Implemented.** Two Python functions set `resources.duration` from an HLS playlist fetched via CloudFront host `d1ogvodmq23438.cloudfront.net`. One is scheduled. One expects an S3 object-created event.

**Inferred.** Together this is the Cape Town half of Ear Academy's media catalog plus the timers that hit the public site's admin cron routes. **Requires Confirmation** that this is still the live design.

The checked-in yaml `Description` fields say "A starter AWS Lambda function" or are empty. They do not state this purpose.

## Users and interactions

No login, role, or screen exists in this repository.

| Actor | What they do | Evidence | Status |
|---|---|---|---|
| EventBridge | Invokes three functions on the schedules written in the templates | `Schedule` events on `phpCronEveryDay`, `phpCronTrigger`, `videoDuration` | **Implemented** |
| Something outside this export | Would have to invoke the 2am, 6am, and 8am functions. No event is declared | Those three yaml files have no `Events` | **Unknown / Requires Confirmation** |
| S3 | Delivers `ObjectCreated` to the catalog and duration-on-put functions | `BucketEvent1` on an unnamed `Bucket1` | **Implemented** in the template shape. The live bucket name is **Unknown** |
| HTTP caller | `POST /create`, `GET /sort_files` | `Api` events. No authorizer field | **Implemented** as routes. Auth is **Unknown / Requires Confirmation** |
| CloudFormation | Would invoke the existing-S3 custom resource with `RequestType` Create, Update, or Delete | `s3/handler.js`. No event in the yaml | **Implemented** as code. Wiring is **Unknown** |
| Ear Academy admin application | Receives the HTTP GETs. Its behaviour is not in this repo | URL strings in `index.js` | **Unknown** beyond the paths |
| Deploy operator | Not represented. The pipeline only runs SonarQube | `bitbucket-pipelines.yml` | **Implemented** |

`create.js` comments say `userId` is a Cognito Identity Pool id. The exported API event does not declare a Cognito or IAM authorizer. The sibling source `theear_transcode` declares `authorizer: aws_iam` for `POST /create`. Provenance: **Discrepancy.** The live authorizer is **Unknown / Requires Confirmation**.

## Systems the product talks to

1. `https://www.ear-academy.com` over HTTPS GET. No request headers are set.
2. Amazon S3 buckets named in code: `the-ear-prod-resources`, `the-ear-prod-video-upload` (client region `eu-west-1`), `the-ear-staging-resources` (client region `af-south-1`).
3. MySQL database `the_ear_prod_db_a` on host `the-ear-prod-db-a.cluster-coaufliysybm.af-south-1.rds.amazonaws.com`. Table `resources`.
4. Amazon DynamoDB `DocumentClient`. Table `process.env.tableName`, set to `loging` on the create template.
5. CloudFront `https://d1ogvodmq23438.cloudfront.net` plus the S3 key path, from both Python functions.
6. API Gateway, for the two HTTP routes. Stage, API id, and authorizer are not in the export.
7. IAM roles in account `055752874982`. Policy documents are not in the repo.
8. Lambda layer `arn:aws:lambda:af-south-1:055752874982:layer:packages:2` on the Python functions.

No email, queue, SNS, or Elastic Transcoder call is active in these handlers. The catalog bundle comments out `AWS.ElasticTranscoder` and comments out a copy to `the-ear-prod-video-upload`.

## Major workflows

### WF-1 — Daily admin ping

Trigger: `phpCronEveryDay`, schedule `cron(0 6 * * ? *)`. EventBridge cron is UTC, so this is 06:00 UTC. Timeout 3 seconds. Memory 128 MB. Runtime nodejs12.x.

The handler loops this list and calls `https.get` on each URL. It does not wait for the set to finish. The first response calls `context.succeed()`. An error calls `context.done(null, 'FAILURE')`. No `Authorization` header is set.

Paths under `https://www.ear-academy.com`:

- `/admin/run_cron_daily/CheckIn`
- `/admin/run_cron_daily/TrialFeedback`
- `/admin/run_cron_daily/TrialExpiration`
- `/admin/run_cron_daily/EndOfFreeTrial`
- `/admin/run_cron_daily/StillDeciding`
- `/admin/run_cron_daily/SendUserPurchases`
- `/admin/run_cron_daily/PaymentFailureOnSubscriptionRenewal`
- `/admin/run_cron_daily/NotificationOfSubscriptionChange6Months`
- `/admin/run_cron_daily/NotificationOfSubscriptionChange12Months`
- `/admin/run_cron_daily/CancelSubscriptionOnPaymentFailure`
- `/admin/run_cron_daily/DeactivateDowngradedCourses`
- `/admin/run_cron_daily/GenerateCreditNote`
- `/admin/run_cron_daily/RevokeOnceOffAccess6Months`
- `/admin/run_cron_daily/RevokeOnceOffAccess12Months`
- `/admin/run_cron_daily/RevokeCancelledMonthlySubscriptions`
- `/admin/run_cron_daily/UpdateDiscountedSubscriptionAmounts`
- `/admin/run_xero_manual_journals_cron`
- `/admin/run_cron_daily/RevokeCoupon100Access`

What each path does on the PHP application is **Unknown**. Provenance of the call list: **Implemented** in `phpCronEveryDay` `index.js`.

### WF-2 — Hourly admin ping

Trigger: `phpCronTrigger`, schedule `rate(1 hour)`. Same request pattern and 3 second timeout.

- `/admin/run_cron_hourly`
- `/admin/run_cron_daily/GetPayfastDailyTransactions`
- `/admin/run_cron_daily/GenerateInvoices`

### WF-3 — Named time-of-day pings with no schedule in the export

If invoked, the handlers GET:

| Function | URL | Runtime |
|---|---|---|
| `phpCronEveryDay2am` | `/admin/run_cron_daily/GetPayfastDailyTransactions` | nodejs14.x |
| `phpCronEveryDay6am` | `/admin/run_cron_daily/GenerateInvoices` and `/admin/run_cron_daily/SendUserPurchases` | nodejs14.x |
| `phpCronEveryDay8am` | A string that begins with a space, then `https://www.ear-academy.com/admin/run_ear_cron/SendUserPurchases` | nodejs16.x |

`phpCronEveryDay8am` uses IAM role `phpCronEveryDay-role-o7rgabdc`, the same ARN as `phpCronEveryDay`, not a role named for 8am. The 2am and 6am functions have their own role ARNs.

The leading space is in the source string. `https.get` of that string cannot be a valid URL. Provenance: **Implemented**.

`GetPayfastDailyTransactions` is also on the hourly function. `GenerateInvoices` is also on the hourly function. `SendUserPurchases` is also on the daily function, at path `run_cron_daily` rather than `run_ear_cron`. Provenance: **Implemented** overlap. Whether that overlap is intentional is **Unknown / Requires Confirmation**.

### WF-4 — Catalog an object into `resources`

Trigger: template event `s3:ObjectCreated:*` on `Bucket1` for `theearresourcesproductiontranscode`. Handler `transcode.main`. Memory 512 MB. Timeout 6 seconds. VPC attached. Runtime nodejs12.x. Tag `STAGE: production`.

The template's `Bucket1` is an `AWS::S3::Bucket` with no properties. The live bucket name is not in the yaml. The handler ignores the event bucket name and writes URLs under `s3://the-ear-prod-resources/`.

For each record, classify by `endsWith` (case-sensitive):

| Key ends with | `type` stored | `url` stored |
|---|---|---|
| `.m3u8` | `m3u8` | `s3://the-ear-prod-resources/` + full key |
| `.mxf` | `mxf` | same |
| `.pdf` | `pdf` | same |
| `.mid` | `midi` | same |
| `.png` | `png` | same |
| `.mp3` | `mp3` | same |
| `.mp4` | `mp4` | same |

`.m3u8` is an `if` branch. The other suffixes are in the `else`, so a key ending in `.m3u8` is not also classified as another type. `duration` is the string `"0"`. `name` is the last `/` segment of the text before the first `.`.

If `url` is longer than 2 characters the function runs an `INSERT` into `resources (name, url, type, duration, created)`. `created` is UTC `YYYY-MM-DD HH:MM:SS`. There is no existence check. The file name and URL are concatenated into the SQL text.

`url`, `type`, and `duration` are declared outside the record loop and are not cleared when a key matches nothing. A later unmatched key can insert the previous key's URL. Provenance: **Inferred** from variable scope. Same pattern as the sibling `sort_files` note in `theear_transcode`.

The loop is `forEach(async ...)`. `mysql.end()` runs after the loop is scheduled, not after the queries finish. Query errors are logged. The wrapper returns HTTP 200 with `Access-Control-Allow-Origin: *` when the async function resolves. S3 does not use that response.

A copy of `.m3u8` objects to `the-ear-prod-video-upload` is commented out in this handler.

### WF-5 — Backfill from the staging bucket

Trigger: `GET /sort_files`. Memory 512 MB. Timeout 6 seconds. No authorizer field. Same VPC and database environment as WF-4.

The handler lists `the-ear-staging-resources` with one `listObjects` call (no prefix, no continuation token) and, for `.m3u8`, `.pdf`, `.mid`, `.png`, and `.mp3`:

1. `SELECT * FROM resources WHERE name="{file}" AND type="{type}"`.
2. If `rows.length == 0`, `INSERT` the same columns as WF-4.

`.m3u8` URLs are `s3://the-ear-staging-resources/transcode/{stem}/{file}.m3u8`, not the listed key, and the stored `type` is `mxf`, not `m3u8`. Other matching suffixes use `s3://the-ear-staging-resources/` + the listed key and the type names in BR-016 of `business-rules.md`. `.mp4` and `.mxf` are not classified. `duration` is `"0"`. The same uncleared-`url` behaviour applies.

The HTTP body is `JSON.stringify` of `undefined` when the handler returns nothing. Provenance: **Implemented** in the bundle's `handler-lib` plus `sort_files.js`.

### WF-6 — Create a DynamoDB note

Trigger: `POST /create`. Memory 512 MB. Timeout 6 seconds. No authorizer field in the export.

`JSON.parse(event.body)` then `DocumentClient.put` of:

- `userId`: `event.requestContext.identity.cognitoIdentityId`
- `noteId`: UUID v1
- `content`: `data.content`
- `attachment`: `data.attachment`
- `createdAt`: `Date.now()`

Table name is `process.env.tableName` (`loging` in the template). Success HTTP 200 returns the item. DynamoDB errors return HTTP 500 and `{ "status": false }`. CORS headers are `Access-Control-Allow-Origin: *` and `Access-Control-Allow-Credentials: true`. A body that is not JSON throws before the callback. The handler does not read the database environment variables that the template sets.

Provenance: **Implemented**. This is the starter notes handler. It does not create a `resources` row.

### WF-7 — Copy one object to the video-upload bucket

Handler `theearresourcestoupload` `index.handler`. Memory 128 MB. Timeout 3 seconds. No `Events` block. VPC attached. Role `theear-resources-production-af-south-1-lambdaRole` (shared with create, sort_files, transcode, and the custom resource).

Reads `event.Records[0].s3.object.key`. Copy source is `the-ear-prod-resources/{key}`. Destination bucket and key are `the-ear-prod-video-upload` and the same key. The S3 client is constructed with `region: eu-west-1` and `Bucket: the-ear-prod-video-upload`. Errors are logged. The callback is not invoked. `.promise()` is called and not returned. A missing `Records` array throws.

Provenance: **Implemented** as code. The event source that would invoke it is **Unknown / Requires Confirmation**.

### WF-8 — Fill duration on a schedule

Trigger: `videoDuration`, `cron(0 18 * * ? *)` (18:00 UTC). Memory 128 MB. Timeout 600 seconds. Python 3.9. Layer `packages:2`. VPC attachment is the second pair (not the Node VPC).

Connects with `pymysql` using `db_host`, `db_user`, `db_password`, `db_database` from the environment. `autocommit=True`.

```text
SELECT * FROM resources
WHERE status = 'active' AND type = 'm3u8'
  AND url NOT LIKE '%ts.m3u8%'
  AND duration IN ('0', '5')
```

For each row, take `urlparse(row['url']).path` and load `https://d1ogvodmq23438.cloudfront.net` + that path. If the playlist has variant playlists, follow `playlists[0].absolute_uri`. Otherwise sum segment durations and cast to int. On any exception, `duration_by_url` returns `0`.

Then `UPDATE resources SET duration = %s WHERE id = %s` with bound parameters. Returns HTTP 200 and body `"Done"`. The connection is closed in `finally`.

A commented line would have used `https://the-ear-prod-resources.s3.af-south-1.amazonaws.com` instead of CloudFront.

Provenance: **Implemented**. Why duration `'5'` is included is **Unknown**.

### WF-9 — Fill duration when an object is created

Trigger: `VideoDurationOnPut`, template `s3:ObjectCreated:*` on another empty `Bucket1`. Same runtime, layer, role, and VPC as WF-8. Timeout is 3 seconds, not 600.

Reads the bucket and key from the event. Selects:

```text
SELECT * FROM resources
WHERE url LIKE '%{key}%' AND status = 'active' AND type = 'm3u8'
```

The key is concatenated into the SQL text. There is no `ts.m3u8` exclusion.

Builds `s3://{bucket}/{key}`, then the same CloudFront URL from `urlparse(uri).path`, and the same playlist walk. On exception the function prints the `Exception` class and returns `None` (it does not return `0`). The update concatenates that value into SQL: `UPDATE resources SET duration = '{seconds}' WHERE id = '{id}'`. `autocommit=True`. The explicit `commit` is commented out.

Returns HTTP 200 and `"Done"`.

Provenance: **Implemented** in `VideoDurationOnPut` `lambda_function.py`, added in `1539c1d`.

### WF-10 — Attach a function to an existing bucket

`s3/handler.handler` switches on CloudFormation `RequestType`:

- Create: `addPermission` for the named function on the named bucket, then `putBucketNotificationConfiguration`.
- Update: replace notification entries whose id starts with the function name.
- Delete: remove the permission and the notification entries.

`ResourceProperties` used: `FunctionName`, `BucketName`, `BucketConfigs`. Other handlers in the same zip are not the template entry point.

Provenance: **Implemented** package. It is the Serverless custom resource described by the one-line `README.md` in the zip ("Lambda functions for the Serverless Custom CloudFormation Resources"). It is not Ear Academy business logic.

## Functional requirements

### FR-001 — Daily ping of the admin cron list

The daily function requests every URL in WF-1 when EventBridge fires `cron(0 6 * * ? *)`.

- Actor: EventBridge.
- Inputs: none from the event. The URL list is constant.
- Behaviour: WF-1.
- Output: one HTTPS GET per URL. The Lambda result is whatever `context.succeed` or `context.done` records for the first completed request.
- Validation: none. HTTP status is logged and not retried by the function.
- Errors: socket errors log `e.message` and call `context.done(null, 'FAILURE')`. The function still has Lambda's own retry settings (`MaximumRetryAttempts: 2`, `MaximumEventAgeInSeconds: 21600`).
- Dependencies: public DNS and TLS to `www.ear-academy.com`, and whatever those routes require. No credential is sent by this function.

Evidence: `phpCronEveryDay.yaml`, `index.js` in `phpCronEveryDay-151a474c-22fb-4c0e-9995-32a124a6b05a.zip`.

Implementation: **Implemented**. Confidence: **High** for the code. **Unknown** whether the schedule is still enabled.

### FR-002 — Hourly ping

`phpCronTrigger` requests the three URLs in WF-2 on `rate(1 hour)`.

Evidence: `phpCronTrigger.yaml` and its zip.

Implementation: **Implemented**. Confidence: **High** for the code.

### FR-003 — Time-named pings

`phpCronEveryDay2am`, `phpCronEveryDay6am`, and `phpCronEveryDay8am` request the URLs in WF-3 when something invokes them. The export does not contain their schedules.

Evidence: the three yaml files and their `index.js` zips.

Implementation: **Implemented** as invocable code. The schedule is **Unknown / Requires Confirmation**. Confidence: **High** that the yaml has no `Events`.

### FR-004 — Insert a resources row for a known object

The transcode function inserts one `resources` row per matching record, as in WF-4, with no duplicate check.

- Actor: S3 event.
- Inputs: object key. Database settings from `databaseHost`, `databaseName`, `databaseUser`, `databasePass`.
- Output: a MySQL insert. Duplicate calls insert again.
- Validation: suffix only. `url` length must be greater than 2.
- Errors: query failures are logged and do not throw.
- Dependencies: MySQL `resources` and a network path from the VPC.

Evidence: `theear-resources-production-transcode.yaml` and `transcode.js` in its zip (module `region_south_africa/transcode.js`).

Implementation: **Implemented**. Confidence: **High** for the code. The live bucket and database are **Unknown**.

### FR-005 — Backfill from staging without duplicating name and type

`GET /sort_files` inserts only when no row has that `name` and `type`, as in WF-5.

- Actor: HTTP GET. No authorizer in the template.
- Inputs: none from the query string. Bucket name is constant.
- Output: inserts, and an HTTP 200 whose body is the JSON `null` from an undefined return (via `handler-lib`).
- Validation: suffix checks. A single `listObjects` page.
- Errors: query errors are logged.

Evidence: `theear-resources-production-sort_files.yaml` and `sort_files.js`.

Implementation: **Implemented**. Confidence: **High**.

### FR-006 — Store a note item

`POST /create` stores the DynamoDB item in WF-6.

- Actor: HTTP POST. Authorizer is not in this export.
- Inputs: JSON `content` and `attachment`. Identity id from the request context. Missing identity throws when the property is read.
- Output: the item, or HTTP 500 `{ "status": false }`.
- Validation: `JSON.parse` only. No schema for `content` or `attachment`.
- Dependencies: DynamoDB table `loging` and permission on the shared resources role. The role policy is not in git.

Evidence: `theear-resources-production-create.yaml` and `create.js`.

Implementation: **Implemented**. Confidence: **High** that the route and handler exist. **Unknown** whether any client calls it.

### FR-007 — Copy a key to the video-upload bucket

When invoked with an S3 record, `theearresourcestoupload` copies that key as in WF-7.

Evidence: `the-ear-resources-to-upload.yaml` and its `index.js`.

Implementation: **Implemented** as code. Confidence: **High** for the copy parameters. The trigger is **Unknown**.

### FR-008 — Set duration for active master playlists stuck at 0 or 5

The scheduled Python function updates `duration` for rows matching WF-8, using a parameterised update.

Evidence: `videoDuration.yaml` and `lambda_function.py`.

Implementation: **Implemented**. Confidence: **High** for the code. Playlist and database reachability are **Unknown**.

### FR-009 — Set duration from an object-created event

`VideoDurationOnPut` updates matching `m3u8` rows as in WF-9.

Evidence: `VideoDurationOnPut.yaml` and its `lambda_function.py`.

Implementation: **Implemented**. Confidence: **High** for the code. The 3 second timeout against a playlist download is a behaviour of the template, not a confirmed product limit. **Requires Confirmation** whether that timeout was intentional.

### FR-010 — Custom resource manages an existing bucket notification

The packaged handler adds or removes a Lambda permission and a bucket notification for the properties CloudFormation passes.

Evidence: `s3/handler.js`, `s3/lib/bucket.js`, `s3/lib/permissions.js`.

Implementation: **Implemented**. Confidence: **High** that this is framework code. Which stack still calls it is **Unknown**.

## Constraints

- All twelve templates set `MaximumEventAgeInSeconds: 21600`, `MaximumRetryAttempts: 2`, ephemeral storage 512 MB, architecture `x86_64`, `SnapStart.ApplyOn: None`, `RuntimeManagementConfig.UpdateRuntimeOn: Auto`, `PackageType: Zip`.
- PHP-cron and upload functions are 128 MB and 3 seconds. `videoDuration` is 128 MB and 600 seconds. `VideoDurationOnPut` is 128 MB and 3 seconds. Catalog, backfill, and create are 512 MB and 6 seconds. The custom resource is 1024 MB and 180 seconds.
- Node handlers that talk to MySQL read `databaseHost`, `databaseName`, `databaseUser`, `databasePass`. The templates also set `DBHOST`, `DBNAME`, `DBUSERNAME`, `DBPASSWORD`, `SUBNETIDS1`, `SUBNETIDS2`, `SECURITYGROUPIDS`. Those extra names are not read by the handlers inspected.
- Python handlers read `db_host`, `db_user`, `db_password`, `db_database`.
- Suffix checks are case-sensitive.
- Catalog `duration` is the string `"0"` until a Python function updates it.
- SQL in the Node catalog, the Node backfill, and `VideoDurationOnPut` concatenates names, URLs, or keys into the statement text. The scheduled duration update uses placeholders.
- `CodeUri: .` does not match the zip layout. The code files are inside the sibling zip, not loose next to the yaml. A deploy of the yaml as stored would not see `index.js` unless the zip were unpacked. Provenance: **Inferred** from the tree. Nobody documented a deploy command.

## Integrations

| System | Direction | Trigger | Data | Auth as shown | Failure |
|---|---|---|---|---|---|
| `www.ear-academy.com` | Out | Schedules in WF-1 and WF-2; WF-3 only if invoked | HTTPS GET, no body, no headers | None in the client | Logged. First completion ends the invocation |
| S3 resources bucket | In | Object created, bucket name not in yaml | Key | Lambda role, policy not in git | Unknown suffix: no insert, unless a previous URL is still in scope |
| MySQL `resources` | Out | After classify, backfill, or duration | Insert or update | User `admin` and a password from env | Node: logged. Python schedule: exception in `duration_by_url` becomes duration 0 |
| S3 `the-ear-staging-resources` | Out | `GET /sort_files` | Object list, one page | Shared resources role | List errors reject the handler |
| DynamoDB `loging` | Out | `POST /create` | Note item | Shared resources role. Region of the client is not set in code | HTTP 500 |
| S3 `the-ear-prod-video-upload` | Out | Upload function, trigger not in yaml | Copy of one key | Shared resources role. Client region `eu-west-1` | Logged |
| CloudFront `d1ogvodmq23438` | Out | Duration functions | HLS playlist | None in code | Schedule: duration 0. On-put: `None` written into the update text |
| API Gateway | In | POST `/create`, GET `/sort_files` | JSON body or empty | Not declared in the export | Unparseable JSON throws on create |
| CloudFormation | In | Custom resource | Function name, bucket, configs | Shared resources role | Unhandled `RequestType` throws |

## Acceptance notes for a reader

A tester can derive cases from FR-001 to FR-010. This repository contains no test files and no fixtures. SonarQube is the only automated step, and it does not execute the handlers.

## Discrepancies with `theear_transcode`

| Topic | This export | `silicon_ark/theear_transcode` (source at `fda49df`) |
|---|---|---|
| `POST /create` authorizer | Not in the SAM event | `authorizer: aws_iam` |
| `sort_files` / `transcode` timeout | 6 seconds in the SAM export | Provider default for `sort_files` and `create`; `transcode` timeout 60 seconds in `serverless.yml` |
| Copy to `the-ear-prod-video-upload` | Separate function `theearresourcestoupload` | Commented out inside `transcode.js`. A policy JSON exists and is not referenced |
| Duration | `videoDuration` and `VideoDurationOnPut` | Not described. Catalog always stores `"0"` |
| PHP cron pings | Five functions | Not in that repository |
| Ireland Elastic Transcoder | Not in this export | Wired in `region_ireland` |

These are records of two trees, not a decision that one is wrong.
