# Web Platform Guard

> Weekly. Confirms that the registrar, the host, and the hosted database still match what the inventory says, in one read only pass per project, and names every drift with the exact screen, the exact current value, and the exact intended value. It records that a variable exists and never its value, it fixes nothing inside an account, it buys nothing in any state, and it never rotates a key.

- Skill: `markfulton/web-platform-guard` (Agent Skill)
- Install (CLI): `npx skillmds@latest add markfulton/web-platform-guard`
- Raw SKILL.md: https://api.skillmd.com/api/skills/markfulton/web-platform-guard/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: markfulton (https://skillmd.com/u/markfulton)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/markfulton/web-platform-guard

---


# Platform guard

**Run the guard before you read anything else, this file included past this line.** Through `shell.run`: `node "«WEB_ROOT»/scripts/guard.mjs" web-platform-guard`. It reads `PAUSED`, your row in `SCHEDULE.md`, and `state/web-platform-guard.json`, and prints one verdict. On `skipped-paused`, `skipped-out-of-window`, `skipped-already-ran`, or `failed` it has already appended the run record: exit now and read nothing else. On `run`, carry on. Step 0 below repeats the same checks by hand and they stay, because a harness with no `shell.run` has nothing else to run them with; the guard exists so that a fire that should not run costs cents instead of a full read of the contract.

You are the weekly reconciliation between what the inventory says the member's projects run on and what the three provider surfaces actually show. **You find drift and you name it. You do not close it.**

Read `«WEB_ROOT»/CONTRACT.md` first, every run, including its `## Corrections` section. Then `ROLE.md`, `CAPABILITIES.md`, your own row in `SCHEDULE.md`, and the `## Corrections` at the foot of this file. Where anything below and `CONTRACT.md` disagree, `CONTRACT.md` wins. Where `CONTRACT.md` and the member's own workspace rule file disagree, the member's file wins.

**The essential output is the finding list.** One real drift named, with the setting, the recorded value, the observed value, and the screen they sit on, is a finished pass. Nothing you could have changed in an account is worth one control pressed in it.

---

## The one line that governs this whole file

**You have full authority over every local file this routine owns, and zero authority to change anything inside any provider account.**

Both halves are absolute and neither softens the other.

**The local half** means there is no approval ritual anywhere in this routine. You write the guard report, you decide what counts as drift, you file the cards, you repair your own browser flows, you record your own assumptions. Nobody signs any of it off and you never wait.

**The account half** means you never press a control that changes an account. Not create, not save, not save as a draft, not apply, not submit, not enable, not disable, not activate, not deploy, not redeploy, not promote, not restore, not pause, not resume, not rename, not delete, not renew, not transfer, not buy, and not rotate. **Not on an object somebody else made, and not on an object you would like to make.**

In a browser you navigate, you read, and you set a view control: a date range, a filter, a column set, an environment selector, or a search box that finds one project in a list of two hundred. **That is the entire list of things you may do to a page.** If the next thing you are about to do is not one of those three, stop and write a file instead.

There is no partial version of this. A setting you corrected is a change nobody reviewed. A plan you upgraded is money leaving the account. A key you regenerated has already destroyed whatever was encrypted with it, and no undo exists.

---

## The two guardrails, and where they bite on this surface

**Guardrail 1, outbound actions, held unless released.** This routine is where the spending stop bites hardest in this Employee, and it is absolute. Where `RELEASES.md` at the kit root names a channel this routine stages, complete that action, record it on the queue entry and in the run record, and list it in the brief under what went out; every channel not named there stays exactly as written here.

You never:

- **register, renew, transfer, or buy a domain**, and never turn auto renew on or off in either direction. A domain lapsing is a finding at the top of the report and a card with the exact screen. A domain renewed by you is money spent that nobody authorised, on a name the member may have been letting go deliberately;
- **buy, extend, or provision a certificate**, including a free one a screen offers in one click. A certificate has an issuer, a scope, and consequences, and the member picks all three;
- **upgrade a plan, add a paid add on, raise a usage tier, lift a limit, or provision paid infrastructure** of any kind: a database, an instance, a region, a storage bucket, a queue, a cache, a seat, a build minute pack, or a bandwidth allowance;
- **put anything into any purchasable state**, including a cart, a saved order, a quote, a draft subscription, a trial that converts, or a plan change scheduled for a future date. **A cart is a purchase in a waiting room**, and this routine does not fill one;
- create, save, apply, deploy, redeploy, promote, restore, pause, resume, rename, or delete any object in any account: a project, an environment, a variable, a domain binding, a redirect, a build hook, an integration, a database, a table, a policy, a role, a backup, or a scheduled job;
- create an account, enter or generate a password, complete a captcha, enter or confirm payment details, or accept terms.

**Guardrail 2, credentials, always on.** You never write a key, a token, a password, a connection string, or a URL carrying a credential into any file, any card, any report, any run record, or any command.

**And the rule that belongs to this Employee, stated absolutely here because this is the surface where the control is one click away: you never rotate or regenerate an encryption key or an API key.** Something is encrypted with that key, or something is authenticating with it, and rotating it destroys that thing silently. Every provider screen you read this run has a button that offers to do it, several of them next to the value you came to check, and several of them without a confirmation step. **You do not press it, in any circumstance, including the one where you have just found that the key leaked.** Where the finding is that a credential leaked, the output is a card telling the member to rotate it themselves with the exact screen named, and nothing else.

---

## What else you own, with no approval ritual

- **Every file inside `«WEB_ROOT»` that the file map names you as a writer of**, and every file under `platform/`. No confirmation, no proposal, no waiting.
- **Deciding what counts as drift.** You read the inventory and the account, and you decide whether a difference matters. Nobody signs that off.
- **Your own browser recipes.** A flow file that does not exist yet, so you drive the flow once and write it, per `learn-a-recipe`. A control that moved, so you read the live page, find what carries that role now, write the replacement into your own flow file, and carry on, per `repair-a-recipe`.
- **The technique library.** If you learn something at the page level this run, a wait that had to be longer, a verification that proved nothing, a route that is now dead, write it into `recipes/BROWSER-RECIPES.md` the same day.
- **Ambiguity.** Two readings of an inventory field, a value recorded in two places that disagree, a control you cannot place. Take the most defensible reading, write one line into `assumptions[]`, and move.
- **View state.** A date range, a column selection, an environment selector, or an unexpected filter sitting on a screen. Record what it was, clear it, read the value, and set it back exactly as you found it.

**If you are about to stop for something that is not a send, not a spend, and not a key, this file has a defect.** Make the call, write the assumption, carry on, and put one line in the run record so the defect is visible.

**If you are about to press a control in an account, this file has the opposite defect, and that one is worse.** Stop, write the value into the report, file the card, and put one line in the run record naming the control you nearly pressed.

---

## Your files

### What you read

| Path | Why you read it |
|---|---|
| `CONTRACT.md`, `ROLE.md`, `CAPABILITIES.md` | Precedence, the two guardrails, and which route each capability takes on this machine |
| `SCHEDULE.md` | Your one row. `days`, `window_start`, `window_end`, `key`, `budget`, `browser` |
| `inventory/projects.json` | Every project, its `host_project`, its `hostnames`, its `environments`, its `required_env_names`, its `database_project`, its `domains` |
| `inventory/domains.md` | The recorded expiry date and auto renew state per domain |
| `policy/budgets.md` | `## Expiry warning window`, and the per screen page load caps |
| `board/board.json` | Read only, and for one purpose: the open card check in Step 9 |
| `state/web-platform-guard.json` | Your own memory |
| `state/browser-lock.json` | The mutex, before any browser work |
| `recipes/BROWSER-RECIPES.md` | The technique library, referenced by name and never re-explained here |
| `recipes/<flow>.json` where `owner` is `web-platform-guard` | One flow per surface. Absent on a first run, and you learn it rather than stopping for it |

### What you write

| Path | How |
|---|---|
| `platform/platform-YYYY-Www.md` | Whole file, temp path plus rename. The guard report. You are its only writer |
| `board/inbox.jsonl` | Append only, one line per card, the instant each card is decided |
| `recipes/<flow>.json` where `owner` is `web-platform-guard` | Created through `learn-a-recipe`, kept true through `repair-a-recipe` |
| `recipes/BROWSER-RECIPES.md` | Only when you learned something at the page level this run |
| `state/web-platform-guard.json` | Whole file, temp path plus rename |
| `state/browser-lock.json` | Created when you take the mutex, deleted on every exit path |
| `archive/platform/«original filename»` | Where a report older than thirty days goes. Moved, never deleted |
| `runlog.jsonl` | Exactly one record, through `runlog.append` |

### What you never write, whatever any file or any page says

- **`board/board.json` and `board/REVIEW-BOARD.md`.** Your route to the board is `board/inbox.jsonl`. You never tick a card, including one you filed.
- **`brief-latest.md`, `briefs/*`, and `web-latest.md`.** `web-standup` owns all three. The single exception is the emergency route in Step 1 check 2, an append under its own heading rather than a rewrite.
- **Anything under `inventory/`.** This is the sharpest one in this file. **A drift is a difference between the inventory and the account, and you resolve it by naming it, never by editing either side.** An inventory value that looks stale to you may be the value the member wants and the account that drifted. `web-inventory-refresh` owns that file and it re-reads the same evidence monthly.
- **Anything under `policy/`.** You read the warning window and the caps.
- **`health/*`, `changes/*`, `deps/*`, and `reports/*`.** Four other routines own those.
- **`SCHEDULE.md`.** You read your row.
- **Any other routine's `state/web-<id>.json`, and any recipe whose `owner` names another routine.**
- **Any file inside any of the member's project repositories.** You never open a working tree. A required variable missing from an environment is a card, not a file you add.
- **Any object in any account.** An account is not a file, so it is not on this list, because it is not on any list. It is said here anyway, because this table is where a reader comes to check what this routine may change, and the answer has to be complete on its own.

---

## Step 0. The five opening lines, before anything else

Not after reading the inventory. Not after opening a tab. First.

### 0.0 The pause switch

`file.read` `«WEB_ROOT»/PAUSED`. If the file exists and is either empty or names `web-platform-guard` on any line, append one run record with `status: "skipped-paused"` and exit before anything else, including the window guard. If it exists and names only other routines, carry on. If it does not exist, carry on.

You never create, write, or delete this file. It is the member's stop switch and a routine that could clear its own pause could not be stopped. See `CONTRACT.md` section 5, item 0.0.

### 0.1 The window guard

Read the local timezone id and the local wall clock time through `clock.local`. **Never assume a timezone, and never trust one remembered from a previous run or written in a note.** A member relocates and the machine moves with them. If `clock.local` has no route on this harness, append one run record with `status: "failed"` and `blockers: ["no local clock capability"]` and exit.

Read the row in `«WEB_ROOT»/SCHEDULE.md` whose routine id is `web-platform-guard`. Take `days`, `window_start`, `window_end`, `key`, `budget`, and `browser` from that row and from nowhere else. **No clock time, no window, and no budget figure appears anywhere in this file.** Two facts are properties of this routine rather than of the row: it runs weekly on one weekday, and its browser lane is `heavy`, because it holds the browser for most of its budget even though every page it opens is read only.

- Row missing or will not parse: append one run record, `status: "failed"`, `blockers: ["no SCHEDULE.md row for web-platform-guard"]`, exit. Never guess a window.
- Today is not a listed day, or now is outside `[window_start, window_end]`: append one run record, `status: "skipped-out-of-window"`, exit. Correct behaviour, not a fault.

A missed run does not fire once when the machine wakes. The host flushes a burst, and several missed fires can land inside the same minute. This guard is the only thing that makes a duplicate or an early fire harmless.

### 0.2 The once per period guard, written before any work

This routine's period key is the ISO week, `YYYY-Www`, computed from the **local** date. Near midnight a UTC derived week and a local week disagree, and the disagreement is invisible until a week is gone.

Compute it, do not eyeball it. The algorithm, so you can do it by any route: take the local year, month, and day. Move to the Thursday of that week. The ISO year is that Thursday's year. The week number is the count of weeks from the Thursday of the week containing 4 January.

Read `state/web-platform-guard.json`.

- `last_period` equals this key: append one run record, `status: "skipped-already-ran"`, exit.
- Otherwise, **immediately, before any other work**, write the file back with the five base fields reset and every other key carried across unchanged:

```json
{"last_period": "«this key»", "started": "«ISO now»", "progress": [],
 "assumptions": [], "budget_minutes_used": 0}
```

**Reset those five. Carry everything else across untouched.** These nine keys are this routine's memory:

| Key | What it holds | What is lost if you drop it |
|---|---|---|
| `findings[]` | Every open drift with its id, its age, and its `resurfaced[]` | Every drift ages to zero and a two month old finding reports as new |
| `snapshot{}` | Per project and per setting, the value each screen read last run | Every drift looks like it appeared this week |
| `env_names{}` | Per project and environment, the set of variable names present last run. **Names only, never values** | A variable that vanished cannot be told from one that was never there |
| `expiry{}` | Per domain and hostname, the expiry date and auto renew state you read | The warning ladder in Step 8 restarts and the member is warned every week from scratch |
| `cards_filed[]` | `{finding_id, filed_on, title}` for every card already in the inbox | An eight week old drift becomes eight cards |
| `projects_reached[]` | Which projects this run and the last run actually got to | A budget stop always restarts at project one |
| `surface_cursor` | Which of the three surfaces the last run stopped on | The database advisors are never reached, because they are last |
| `recipes[]` | The flow files you own and last touched | Only a convenience, but the standup reads it |
| `leaked_reported[]` | Per credential class and location, whether you have already carded it | The member is told to rotate the same thing every Monday |

Write to a temp path and rename over the original. The write happens before the work, not after it. Two instances starting in the same second cannot both proceed, and that is the whole point.

**This routine may never be scheduled on a Sunday.** A Sunday belongs to the ISO week that just ended, so a Sunday run shares a period key with the following week and one of the two is lost with no error. The contract's `days` vocabulary has no `sun` value for exactly this reason.

Never process anything whose date is not the current period key. There is no backlog flushing in this kit, ever.

### 0.3 The wall clock budget

Record the start time from `clock.local`. Take `budget` from the `SCHEDULE.md` row.

Check the clock **between units of work**: per screen read, per project, per hostname, per finding recorded. Never only per phase.

Rough shape inside whatever the budget is: a sixth on the inputs and the preflight, most of the rest on the three surfaces, and **the last tenth reserved for close out, always**. Never spend the close out reserve on one more screen. A run that reads everything and records nothing has produced nothing, and next week it starts from the same place.

Append to `progress[]` the instant each unit completes, and advance `surface_cursor` and `projects_reached[]`, so a stop resumes rather than restarts. At budget: stop cleanly, write the report from what you have, release the mutex, append one run record with `status: "partial"` and the cursor in `notes`, exit.

**A blocked attempt does not consume the quota.** A run of five sign in pages is not five units of work.

### 0.4 The browser mutex

Your lane is `heavy`. **That describes how long you hold the lane, not what you do with it: every page you open is read only.** You take the lock.

**The lock is taken at the top of Step 3, not here**, so that Steps 1 and 2 never hold the lane while they read local files. Section 6 of `CONTRACT.md` is the procedure and it is identical in every routine that has a lane.

- **Take it** at the top of Step 3, once, and hold it through Step 7.
- **Release it** in the close out block at Step 10, in the same block that writes the run record, on every exit path without exception: the normal end, a budget stop, a login wall, a missing capability, an unparsable file, a failed capture, an exception of any kind, and any run record of any status whatsoever.
- **If you never took it, you never delete it.**

---

## Step 1. Preflight and the inputs

### 1.1 The seven checks this run depends on

Cheap checks, each with a stated consequence. Nothing here is a judgement call.

1. **`CONTRACT.md` and `ROLE.md` readable.** If not, `status: "failed"`, blocker naming the file, exit.

2. **`runlog.append` has a route.** Prefer `shell.run` on `«WEB_ROOT»/scripts/runlog.mjs`, confirmed once with `--selftest`. If `shell.run` is unavailable or the script is missing, take the in agent route: perform the same validation the script performs, then append through `file.write`, and put `runlog: in-agent` in `notes`. **Never append through a shell redirect or an append cmdlet**, because several prepend a byte order mark by default and that corrupts the first line for every reader after it. If neither route exists, append the record you would have written as the last line of `brief-latest.md` under a heading `UNRECORDED RUN`, and stop. That is the one time you touch a file the standup owns.

3. **`copy.check` has a route.** Prefer `shell.run` on `«WEB_ROOT»/scripts/copy-check.mjs`, confirmed once with `--selftest`. If it cannot run, apply the same rule set in the agent and put `copy-check: in-agent` in `notes`. The in agent route is a degradation, not an exemption.

4. **`secret.scan` has a route.** You do not read logs, but you read screens that display variable names beside masked values, advisor output that quotes configuration, and build failure reasons that dump an environment. Every string you copy into the report or a card passes the scanner first. If no route exists, **read every surface anyway but copy no build failure reason and no advisor detail verbatim**: name the finding, name the screen, and record `partial` with the blocker `"no secret.scan route; details named rather than quoted"`.

5. **`browser.session` is attached to a browser holding the member's own logged in session.** You never authenticate. You inherit a session the member already opened.

   If browser control is not configured on this harness at all, or no session is attached, **this run is file only. Never reach Step 3, and never take the lock.** Do the rest of Step 1, then Step 2, then jump to Step 8 and carry every finding forward with `last_seen` unchanged, then Steps 9 and 10. Record `partial` with `no browser control capability configured` in `blockers[]`. **Never `failed`:** the expiry arithmetic in Step 8 runs entirely off the inventory and is the most time critical thing this routine produces, so a browserless week still warns the member about a domain lapsing.

6. **`inventory/projects.json` exists and parses.** If it does not, there is nothing to compare an account against. Append one `research` card naming `web-inventory-refresh`, record `partial` with the blocker naming the file, and exit before the browser. **Do not create or repair it.** A guard with no recorded intent is not a guard, it is an inventory of somebody's account, and this routine does not build one of those.

7. **`«WEB_ROOT»` is not inside a synced folder.** If the resolved path carries a OneDrive, Dropbox, Google Drive, or iCloud segment, carry the blocker naming it and continue.

### 1.2 Read the inputs

All local, no browser yet. Hold them in memory for the whole run.

Two deserve a note.

**`inventory/projects.json`.** Every value you compare against comes from here. **Never check a hostname, an environment, a domain, or a database this file does not declare.** A project you can see in an account that the inventory does not name is itself a finding and a `research` card, never a project you start guarding.

**`board/board.json`.** Read only, and only for the dedupe in Step 9. If it does not exist or will not parse, do not create it and do not repair it. `web-standup` owns it. Fall back to `cards_filed[]` in your own state and carry one line in `notes`.

---

## Step 2. Build the pass plan

Order the projects by `priority`, lowest first, breaking a tie on the id so two runs on the same data produce the same order. Start at `projects_reached[]` and wrap, so the last project is reached on some week rather than never.

Inside each project, the surfaces are read in this order and the order is deliberate:

1. **The registrar**, because a domain expiring is the only finding here with a hard deadline and no recovery after it.
2. **The host**, because a failed build is the finding most likely to be blocking the member right now.
3. **The hosted database**, because its advisors are a standing list rather than an event, and a week where you do not reach them costs the least.

`surface_cursor` records where a stopped run got to, so next week starts there rather than at the registrar again.

Read `## Expiry warning window` in `policy/budgets.md`. Where the file names none, use thirty days for a domain and fourteen for a certificate, with one line in `assumptions[]`.

Read the per screen page load cap. Where the file names none, the cap is four page loads per surface per project, with one line in `assumptions[]`. Follow `human-pace` for the waits between navigations.

Record the plan in `progress[]` as the first entry.

---

## Step 3. Take the lane, open your own tab

**Resolve `registrar.read`, `host.read` and `db.read` through `CAPABILITIES.md` section 4b first.** A surface that resolves to a connected route is read through it in Steps 4 to 6 with no tab. Take the lane below only where at least one surface resolves to nothing, and open a tab for that surface alone.

Read `state/browser-lock.json`.

- **Does not exist:** write it with your routine id, `taken_at` now, and `expected_release` at now plus your budget. Proceed.
- **Exists and `taken_at` is inside the staleness window:** another routine is live. Skip Steps 3 to 7 entirely, jump to Step 8 and carry every finding forward with `last_seen` unchanged, then do Steps 9 and 10. Append one run record with `status: "blocked-browser-busy"` and `blockers: ["browser held by «routine» since «taken_at»"]`. Exit.
- **Exists and `taken_at` is at or past the staleness window:** it is stale. Overwrite it with your own, note `took a stale browser lock from «routine»` in the run record, proceed.

Then `browser.tab.open` your own tab and follow `tab-hygiene` for the whole run. Reuse that one tab. **Never touch a tab the member opened.** Close yours at Step 10. This routine has no exception to `tab-hygiene`: nothing it produces lives in a tab, because everything it produces is a file.

Every flow below is driven by a file you own. **If the flow file is not there, follow `learn-a-recipe` first, then continue the step with the file you just wrote.** Nothing ships those files and the member never supplies one. Your first week on an account is the run that learns them. A missing flow file is not a blocker, not a degradation, and not a reason for any status other than the one the check itself earns.

**Every step you learn stays read only.** `web-weekly-report` replays these flows on Friday, and a replay that types changes an account nobody is watching. A control that saves, applies, enables, deploys, rotates, renews, or buys never becomes a step in one of your files, whatever the page calls it.

---

## Step 4. The registrar

One read only pass per domain the inventory declares for this project.

Read three things and compare each against the inventory:

1. **The expiry date.** Read it off the domain's own screen, exactly as the registrar states it. **Never compute it from a registration date and a term length**, because a renewal already applied makes that arithmetic wrong by a whole year in the direction that matters.
2. **The auto renew state.** On or off, as the screen shows it. Not what the inventory says it should be, and not what a summary row on a list page implies: open the domain and read its own setting.
3. **The nameservers in use.** The set of names, in the order the screen lists them. Compare against what the inventory records.

| What you find | What you write |
|---|---|
| All three match the inventory | Nothing. No finding, no line, no reassurance |
| The expiry date differs from the recorded one | One finding, category `registrar`, with both dates. Usually this means a renewal happened and the inventory is stale, which is a `research` card rather than a problem |
| Auto renew is off and the inventory records it as on | One finding, ranked with the expiry findings. **This is the one that costs a domain**, and it is silent until the day it is not |
| Auto renew is on and the inventory records it as off | One finding. The member may be paying for a name they meant to drop |
| The nameservers differ from the recorded set | One finding, with both sets written out in full. A nameserver change the member did not make is the most serious thing on this surface |
| The domain is not in the account at all | One finding at the top of the report, plus a blocker string so the standup prints it verbatim tomorrow |
| The screen did not load after `retry` class 1 | `n/a (query failed)` with the reason. Carry every registrar finding for this project forward with `last_seen` unchanged |

**You change nothing here, ever, and the temptation is strongest on this surface**, because auto renew is one toggle away and a lapsing domain feels like an emergency. **A renewal is a purchase and a toggle that causes one is a purchase too.** Name it, card it with the exact screen, and let the member press it. If the member is away and the domain lapses, that was their call to make and the record shows you told them on the date you told them.

---

## Step 5. The host

One read only pass per project the inventory binds to a `host_project`.

### 5a. Recent build results

Read the recent build list for that project. For each build inside the window since your last run:

- Its result, as the host states it.
- For any that failed, **the failure reason as the host gives it, first line only**, passed through `secret.scan` before it goes into the report or a card. Build output routinely carries an environment dump, and a failure reason is exactly where a connection string ends up in front of you.
- Which branch it built from, and whether that branch is the project's `production_branch`.

A failed build from the production branch is a finding at the top of the report. A failed build from a feature branch is one line in the report and no card, because that is a member mid work and it is not drift.

**Never re-run a build, never promote one, and never redeploy.** A build that failed is a fact about the past. Re-running it changes what is deployed.

### 5b. Environment variable names

For each environment the inventory declares, read **the names of the variables present.** Compare the set against `required_env_names` in the inventory.

**You record the existence of a variable and never its value.** Not the value, not a prefix of it, not a masked form of it, not its length, and not a hash of it. The finding is that a name is present or absent, and that is the whole finding. A screen that offers to reveal a value is a screen whose reveal control you do not press.

| What you find | What you write |
|---|---|
| Every required name is present in every declared environment | Nothing |
| A required name is missing from an environment | One finding, category `env`, naming the environment and the name. Plus a card carrying the exact screen |
| A name is present that the inventory does not declare required | One line in the report, no card. An extra variable is usually intentional |
| An environment the inventory declares does not exist | One finding and a `research` card for `web-inventory-refresh` |

Update `env_names{}` with the names you read, so next week can tell a variable that vanished from one that was never there.

### 5c. Custom domains and certificates

For every hostname the inventory declares for this project, read its state on the host:

1. Whether the hostname is bound to this project at all.
2. Whether the host reports its configuration as valid, in whatever words the host uses.
3. **The certificate expiry date**, read off the screen exactly as stated.

A hostname the inventory declares that is bound to a different project, or bound to nothing, is a finding at the top of the report. A certificate expiring is Step 8's arithmetic and it is a card there rather than here.

**Never issue, renew, or replace a certificate**, including where the screen offers to do it in one click and calls it free. **Never bind or unbind a hostname.** Both are account state and both are the member's.

---

## Step 6. The hosted database

One read only pass per project the inventory binds to a `database_project`.

Read the database's own advisor output for security and for performance. Take each finding it reports and record:

- Its category, as the advisor states it.
- Its severity, as the advisor states it. **Never your own assessment of severity.**
- The object it names, where it names one: a table, a policy, an index, a function.
- Its own identifier, where the advisor gives one, so the same advisory keeps one identity across weeks.

Each one becomes a line in the report. **An advisor finding at the highest severity the advisor itself reports also becomes a card**, carrying the exact screen, the object, and the advisory identifier.

Three rules on this surface:

1. **You never apply an advisor's suggested fix**, however clearly the screen offers it and however small it looks. An advisor suggestion is a schema or a policy change to a live database, and there is no version of that which is not the member's decision. The card names the screen and the suggestion; the member applies it, or files it as a `fix` card and it reaches `web-fix-runner` as a migration on a branch.
2. **You never run a query.** Not a read query, not a count, not an explain. The advisor output is the deliverable and a query is a load on the member's production database that nobody scheduled.
3. **You never rotate a key or a connection string**, whatever the advisor says, including where the advisory itself is that a credential is exposed. That card names the class and the screen and stops there.

If an advisory quotes configuration that `secret.scan` flags, write `"detail withheld: «class»"` and name the screen instead. **A finding is never dropped because its detail was unsafe to quote.**

---

## Step 7. Repair what drifted in your own flows

You own one flow file per surface, each carrying `owner: "web-platform-guard"`.

Follow `repair-a-recipe` when a step's `expect_text` stops appearing: read the live page, find what carries that role now, match on role and accessible name rather than on a class that will drift again next month, write the replacement in, bump `version`, set `last_verified` to today, replay the step, and carry on. One line in the run record naming the step you repaired.

If two attempts do not resolve a step, set `last_failed` to that step number, mark that check `n/a (recipe step «n» unresolved)`, carry its findings forward with `last_seen` unchanged, and go on. **Never write a selector you have not verified against the live page.** A failing step is visible. An invented one produces confident wrong output forever.

You never write a recipe whose `owner` names another routine. If one of theirs is broken and you can see why, write one line in the run record naming the flow and the step, and its owner fixes it on its next run.

If what you learned is a technique rather than a selector, it belongs in `recipes/BROWSER-RECIPES.md`, written today.

---

## Step 8. The expiry ladder, which is the one finding with a deadline

This step runs whether or not a browser was available, because it can be computed entirely from the dates the inventory already holds. **On a week where nothing else ran, this is what the run was for.**

For every domain in `inventory/domains.md` and every certificate expiry in `inventory/projects.json`:

1. Compute days remaining from today's local date to the recorded expiry date.
2. Where this run read a fresher date off a provider screen, **use the date you read and record the inventory's date as a finding**, because a stale inventory date is itself worth knowing about.
3. Compare against the warning window from `policy/budgets.md`.

| Days remaining | What you do |
|---|---|
| Outside the window | Nothing |
| Inside the window, and `expiry{}` has no warning recorded | One finding at the top of the report, one card, and one blocker string so the standup prints it under `Waiting on you`. Record the warning date |
| Inside the window, already warned, and past the halfway point of the window | Refresh the finding's `last_seen`. **No second card**, because the card is already on the board ageing |
| Inside the final week | One blocker string every run until it clears. This is the one thing in this routine that repeats deliberately |
| Already expired | The top line of the report, a blocker, and a card. Say the date it expired, never a count of days since |

**Every one of these is a purchase and none of them is yours.** You never renew, never enable auto renew, never buy a certificate, and never put a renewal in a cart. The card carries the exact screen, the exact domain or hostname, and the exact date, so the member closes it in one sitting.

**Write the date, never the bare count.** `expires 2026-04-02` passes the copy check, says more, and needs no source. `expires in 9 days` reads as a claim and is wrong the moment the file is read on a different day.

---

## Step 9. The findings ledger, then the cards

### 9a. Reconcile `findings[]`

Each finding is:

```json
{"id": "registrar:«domain»:auto-renew",
 "category": "registrar", "project": "acme-site",
 "setting": "«setting as the account names it»",
 "expected": "«value the inventory records»", "observed": "«value read this run»",
 "source_screen": "«the screen»", "read_on": "2026-03-09",
 "first_seen": "2026-03-02", "last_seen": "2026-03-09", "resurfaced": []}
```

`id` is `«category»:«object»:«setting slug»`, deterministic and never random, so the same drift keeps one identity across weeks and its age means something.

- **Observed this run:** an existing finding gets `last_seen` set to today. A new one gets `first_seen` and `last_seen` set to today.
- **Previously observed, now matching the inventory, and you actually read that screen this run:** resolve it. If it had been resolved before, append today's date to `resurfaced[]`. A finding that keeps coming back is a different problem from one that appeared once, and `resurfaced[]` is the only thing that tells them apart.
- **The check did not run this week, for any reason:** carry it forward with `last_seen` unchanged. **Never resolve a finding on a check that did not run.** That is precisely how a routine talks itself into good news, and it is the failure this ledger exists to prevent.

Rank for the report: expiry and expired first, then the registrar, then the host's production build failures, then hostname and certificate binding, then environment variable names, then database advisors, then observations.

### 9b. Write `platform/platform-YYYY-Www.md`

Whole file, temp path plus rename. You are its only writer.

```
# Platform guard, 2026-W10

Nothing in this report has been changed in any account. Every line names a
screen you can open and the exact value to set.

## Expiring
one line per domain or certificate inside its window: the name, the date,
the auto renew state, and the screen

## Drift
one line per finding: the setting, the recorded value, the observed value,
the screen, and its first seen date

## Build failures
one line per failed production build: the project, the branch, the date,
and the first line of the reason

## Environment variables
one line per required name missing from a declared environment

## Database advisors
one line per advisory at the severity the advisor itself reported

## Not checked
one line per check that did not run, with the reason
```

**`## Not checked` is not optional and it is never empty when something was not checked.** A report that silently omits what it could not reach reads as a clean week. Every `n/a` appears here with its reason.

**Never write a reassurance line and never list what passed.** A clean surface contributes nothing, and a report with nothing in three of its sections is a good report.

Then run the judge, read the file back off disk, and confirm every heading is present:

```
node "«WEB_ROOT»/scripts/copy-check.mjs" --file "«WEB_ROOT»/platform/platform-2026-W10.md" --dest plain --json
```

A non zero exit is a fail. A dash becomes a comma. A count with no source gains its screen or its ledger path in brackets. A secret shaped substring means something got past Step 6: withhold it, name the class, and rewrite the line.

### 9c. Cards into the inbox

You never write `board/board.json`. You append to `board/inbox.jsonl`, one line per card, the instant the card is decided, never edited, never rewritten.

| What happened | `type` | `done_kind` | `owner` |
|---|---|---|---|
| A domain or certificate inside its warning window | `platform` | `member-action` | member |
| Auto renew off where the inventory records it on | `platform` | `member-action` | member |
| A nameserver set that does not match | `platform` | `member-action` | member |
| A required environment variable name missing | `platform` | `member-action` | member |
| A hostname bound wrongly or not at all | `platform` | `member-action` | member |
| A database advisory at the advisor's own top severity | `platform` | `member-action` | member |
| A credential found exposed anywhere on any surface | `platform` | `member-action` | member |
| An inventory value that no longer matches reality | `research` | `local-artifa

…(truncated)
