# Business rules — ear_lambdas

Evidence date: 2026-10-16.

Source: `git@bitbucket.org:overdrive/ear_lambdas.git`, `master` at `1539c1d` (2023-09-27).

No business-rules document shipped with the source. The PHP routes are names only. What CheckIn, TrialExpiration, Payfast, Xero, or a subscription revoke means in the product is not in this repository. Rules below are the rules the Lambdas actually apply. Each one is **Inferred** unless a commit message or template description states it. None of these are confirmed product decisions.

## When admin URLs are requested

**BR-001 — The daily function requests eighteen fixed URLs and nothing else.** **Inferred** from `phpCronEveryDay` `index.js`. The event payload is ignored. The list is the eighteen paths in WF-1 of `prd.md`, including `/admin/run_xero_manual_journals_cron`, which is not under `run_cron_daily`.

**BR-002 — The daily schedule in the export is 06:00 UTC.** **Inferred** from `cron(0 6 * * ? *)`. The function name does not say 06:00. Whether operators meant 08:00 in South Africa (UTC+2) is **Unknown / Requires Confirmation**.

**BR-003 — The hourly function requests three URLs on `rate(1 hour)`.** **Inferred** from `phpCronTrigger`. One path is `/admin/run_cron_hourly`. The other two are daily-named routes: `GetPayfastDailyTransactions` and `GenerateInvoices`.

**BR-004 — Function names that say 2am, 6am, and 8am do not declare those times.** **Inferred** from the absence of `Events` on those three templates. The names are labels. A schedule that exists only in AWS and was dropped by the console export is **Unknown / Requires Confirmation**.

**BR-005 — The same admin job can be requested by more than one function.** **Inferred** from the URL lists, not from a policy that they should overlap.

| Path | Functions that contain it |
|---|---|
| `run_cron_daily/GetPayfastDailyTransactions` | `phpCronEveryDay2am`, `phpCronTrigger` |
| `run_cron_daily/GenerateInvoices` | `phpCronEveryDay6am`, `phpCronTrigger` |
| `run_cron_daily/SendUserPurchases` | `phpCronEveryDay`, `phpCronEveryDay6am` |
| `run_ear_cron/SendUserPurchases` | `phpCronEveryDay8am` only |

**BR-006 — Cron calls send no credential and do not check the HTTP status.** **Inferred.** `https.get` sets no headers. The status code is logged. Any response, including a non-200, calls `context.succeed()` for that callback. The first completion wins. Whether the PHP routes require a secret, a session, or an IP allow-list is **Unknown / Requires Confirmation**.

**BR-007 — The 8am URL is not a valid URL as stored.** **Inferred.** The string in `phpCronEveryDay8am` `index.js` starts with a space character, and the path is `run_ear_cron`, not `run_cron_daily`.

## What gets a `resources` row

**BR-008 — A catalogued object must end with a known lowercase suffix.** **Inferred** from `transcode.js` in the production transcode zip.

| Suffix | Stored `type` |
|---|---|
| `.m3u8` | `m3u8` |
| `.mxf` | `mxf` |
| `.pdf` | `pdf` |
| `.mid` | `midi` |
| `.png` | `png` |
| `.mp3` | `mp3` |
| `.mp4` | `mp4` |

Any other suffix does not set a new URL. Because `url` is not cleared, it can still insert the previous record's values. That consequence is **Inferred** from scope. It is not written down as a rule.

**BR-009 — The stored URL ignores the event bucket.** **Inferred.** The catalog handler always prefixes `s3://the-ear-prod-resources/`. The SAM `Bucket1` has no name. If the live notification is a different bucket, the row still points at `the-ear-prod-resources`.

**BR-010 — Catalog inserts do not look for an existing row.** **Inferred.** Every matching event inserts. Identity of a resource for this path is not enforced in the Lambda.

**BR-011 — `name` is the last path segment before the first dot.** **Inferred.** `filename.split('.')[0]` then the last `/` piece. Extra dots in the file name are dropped from `name`. The full key still appears in `url` for the catalog handler.

**BR-012 — Catalog `duration` is the string `"0"`.** **Inferred.** The media file is not opened. A later Python function may replace it.

**BR-013 — `created` is the Lambda clock in `YYYY-MM-DD HH:MM:SS`, UTC, space instead of `T`.** **Inferred** from `new Date().toISOString().slice(0, 19).replace('T', ' ')`.

## What the backfill will insert

**BR-014 — Backfill reads `the-ear-staging-resources`, not the prod resources bucket.** **Inferred** from `sort_files.js`. The function is deployed under a production-named Lambda and a production database environment. The bucket in the code is the staging name. Whether that mix is still wanted is **Unknown / Requires Confirmation**.

**BR-015 — Backfill inserts only when no row has the same `name` and `type`.** **Inferred** from `rows.length == 0`. The check is not on `url` or `id`. Two different keys that share a file name and type collapse to one row. A second page of S3 results is never requested (`listObjects` once).

**BR-016 — Backfill types are a subset of catalog types.** **Inferred.** `.m3u8` is stored as type `mxf` on this path, not as `m3u8`. `.pdf`, `.mid` (`midi`), `.png`, and `.mp3` match the catalog type names. `.mxf` and `.mp4` are not classified. The `.m3u8` URL is `s3://the-ear-staging-resources/transcode/{stem}/{file}.m3u8`, which is not the key that was listed.

**BR-017 — An object with an empty stem before the first dot is skipped.** **Inferred** from `if (filename.split('.')[0])` in `sort_files.js`. The catalog handler does not have this guard.

## Duration

**BR-018 — Only active `m3u8` rows are duration candidates.** **Inferred** from both Python queries (`status = 'active'` and `type = 'm3u8'`). Other types keep whatever `duration` they were inserted with (`"0"` on the Node paths).

**BR-019 — The scheduled job skips variant playlists and durations other than 0 and 5.** **Inferred** from `url NOT LIKE '%ts.m3u8%'` and `duration IN ('0', '5')`. Rows already set to any other duration are left alone. Why `5` is treated like "not yet measured" is **Unknown / Requires Confirmation**.

**BR-020 — The on-put job does not skip `ts.m3u8` and does not filter on the current duration.** **Inferred** from `VideoDurationOnPut`. It matches any active `m3u8` row whose `url` contains the new object key.

**BR-021 — Duration seconds come from the CloudFront HLS playlist, not from the database URL's host.** **Inferred.** Both Python files take the URL path and prefix `https://d1ogvodmq23438.cloudfront.net`. A master playlist follows the first variant (`playlists[0]`). The sum of segment durations is truncated with `int`.

**BR-022 — A playlist failure on the schedule stores 0.** **Inferred** from `except: return 0` in `videoDuration`. The row still matches `duration IN ('0', '5')` afterwards, so a later run will try again.

**BR-023 — A playlist failure on put does not return 0.** **Inferred.** `duration_by_url` falls off the end and returns `None`. The update writes that value into the SQL text. The row may then no longer match the scheduled job's `IN ('0', '5')` filter. **Requires Confirmation** of the column type before treating that as a durable outcome.

## Copy and notes

**BR-024 — The upload function copies the first record's key and leaves the source in place.** **Inferred** from `copyObject`. There is no delete. Source bucket is hardcoded `the-ear-prod-resources`. Destination is hardcoded `the-ear-prod-video-upload`. The event's bucket name is not read.

**BR-025 — `POST /create` stores a note, not a media resource.** **Inferred** from `create.js`. Fields are `userId` (Cognito identity id, per the code comment), `noteId` (UUID v1), `content`, `attachment`, `createdAt` (epoch milliseconds). Table name `loging` comes from the template, not from the body.

**BR-026 — Create allows any origin.** **Inferred** from `Access-Control-Allow-Origin: *` together with `Access-Control-Allow-Credentials: true`. The same CORS pair is on the catalog wrapper's HTTP response. No template comment explains it.

## Access

**BR-027 — Twelve functions, four roles.** **Inferred** from the `Role` ARNs. Policies are not in the repo, so what each role allows is **Unknown**.

| Role ARN suffix | Functions |
|---|---|
| `service-role/phpCronEveryDay-role-o7rgabdc` | `phpCronEveryDay`, `phpCronEveryDay8am` |
| `service-role/phpCronEveryDay2am-role-zgmtnn1u` | `phpCronEveryDay2am` |
| `service-role/phpCronEveryDay6am-role-ac2w27q6` | `phpCronEveryDay6am` |
| `service-role/phpCronTrigger-role-u96vukc8` | `phpCronTrigger` |
| `theear-resources-production-af-south-1-lambdaRole` | create, sort_files, transcode, custom resource, resources-to-upload |
| `service-role/videoDuration-role-ezd8bp5t` | `videoDuration`, `VideoDurationOnPut` |

**BR-028 — HTTP routes in the export do not name an authorizer.** **Inferred** from the `Api` events on `POST /create` and `GET /sort_files`. The sibling source declares IAM on create. This export does not. Live API Gateway auth is **Unknown / Requires Confirmation**.

**BR-029 — Database credentials are environment variables, and the values are also stored in git.** **Inferred** from `Environment.Variables` on create, sort_files, transcode, videoDuration, and VideoDurationOnPut. The password value is omitted here on purpose. Rotation and removal from history need a human decision. See `technical-documentation.md`.

## Rules that are not established

- What each `run_cron_daily` name does to subscriptions, invoices, Payfast, or Xero.
- Whether a user, a course, or a payment is in scope of this repository. Those words appear only inside URL paths.
- A retention period, a retry policy beyond the template's two Lambda retries, or an alert.
- Which S3 bucket is allowed to invoke the catalog, the on-put duration function, or the copy function.
- That `duration` `5` means five seconds of media. The scheduled query treats the character `5` like `0`.
