CHANGELOG¶
Completed features, fixes, enhancements, security fixes, and released changes.
Classification (Code Review SOP, CODE_REVIEW_PROMPT.md v1.0, Phase 10): this
file records completed work only. All outstanding work, including
owner-gated work, is a GitHub issue (owner decision 2026-09-05; TODO.md keeps
the accepted risks and an index to the issues); required-input references and
formats belong in Required-Inputs. REVIEW.md was
retired on 2026-08-29 and must not be recreated.
Format follows Keep a Changelog.
This project has not cut a tagged release; entries are grouped under
[Unreleased] and reference the pull request that landed them.
[Unreleased]¶
Added¶
- The lab image builds PyYAML's wheel from its source archive, so the move off Python 3.14 does not wait for PyYAML (#1505). ansible-core imports PyYAML, and the image installs hash-checked wheels only. PyYAML 6.0.3 publishes no wheel for CPython 3.15, and since PyPI stopped accepting new files on a release older than 14 days only a new PyYAML release can add one (yaml/pyyaml#951). The owner's decision of 2026-11-02 is not to wait. The fetch stage of
lab-image/Dockerfilenow downloads PyYAML's source archive by its PyPI SHA256 (PYYAML_SDIST_SHA256in a new, dated block oflab-image/versions.env), installs Cython, setuptools and wheel as hash-checked wheels (PYYAML_BUILD_DEPS, checked against the archive's own build requirements), builds the wheel in a step that runs with no network at all, links libyaml in statically (the runner has no libyaml shared library), and writes the built wheel's own hash into the runner's requirements file in place of the PyPI wheel's line. The runner's offline, hash-checked install does not change; nothing new reaches it. The build stops unless the wheel is a compiledcp3xxwheel whose extension links no libyaml dynamically and imports with the C loader, andsmoke.shchecks the shipped image for the pinned release withyaml.__with_libyaml__true. Checked locally onpython:3.15.2-slim-trixiewith only the base lines moved: the PyYAML step built a cp315 wheel the same way, and the build then stopped at the next step onmarkupsafe==3.0.3, a pin with no cp315 wheel that #1468 moves; PyYAML is no longer what blocks it. Apyyaml-wheelstage holds the built wheel alone, for the workflow in the next entry to publish; thepyyamlline inANSIBLE_CORE_DEPSstays for the host, which installs PyPI's wheel until the entry after next takes effect. The block says it is deleted the day PyYAML publishes a wheel for the base's Python, andscripts/lab-image-pyyaml-wheel.test.mjsholds its shape. - A workflow builds PyYAML's wheel out of the lab image, attests it and attaches it to a release, so the lab host can install it by hash (#1505). ansible-core imports PyYAML, and both the lab image and the lab host install Python packages only as wheels with a known SHA256; PyYAML publishes no wheel for the CPython line the image is moving to, and only a new PyYAML release can add one. The image builds the wheel itself from PyYAML's hashed source archive (the Dockerfile side of #1505); the host cannot build from source (LAB-13), so
publish-pyyaml-wheel.ymltakes the one file out of the image'spyyaml-wheelstage, attests it withactions/attest-build-provenance, and attaches the wheel and its attestation bundle to a release taggedpyyaml-<version>-cp<abi>(pyyaml-6.0.3-cp314today;-cp315once #1468 moves the base), read fromPYYAML_VERSIONandBASE_PYTHON_VERSIONinlab-image/versions.env. Three jobs on thepublish-lab-image.ymlpattern:plan(every trigger, read-only; on a pull request it is the whole run and says what a push would publish),build(push or manual run) andpublish(push tomainor a manual run frommain), which holds the Sigstore and attestations grants and writes the release with the pin pull requests' GitHub App token scoped tocontents: writefor an hour, not with acontents: writegrant, becauseworkflow-write-permissions.test.mjskeeps that list empty. The first wheel published for a tag is the wheel for that tag: a re-run downloads it, checks its SHA256 against the release notes and its provenance withgh attestation verify, and reports the URL and SHA256 as a no-op; a tag whose notes name a different source archive, an asset that does not verify, or a different file appearing during the run fails the run, and--clobberis never used. The run summary names the asset URL, the SHA256 and the attestation.scripts/publish-pyyaml-wheel-workflow.test.mjsholds the grants, the pins (the same aspublish-lab-image.yml's), the timeouts and the no-replace rule, and runs the pin-reading step under bash against a fixture. The runbook isdocs/runbooks/pyyaml-wheel.md, with the PowerShell lines to verify an asset by hand and the list of what to delete the day PyYAML publishes a wheel for the base's Python. -
The lab host takes that published wheel by hash and attestation (#1505).
lab-image/versions.envgainsPYYAML_WHEEL_SHA256at the end of the PyYAML block, the SHA256 of the wheel the workflow attached to this repository's release for the base's Python line (the workflow's step summary prints the line to paste). While it is empty nothing changes:lab-host/bootstrap.shinstalls PyPI's wheel as before and says so in its log. Once it is set,bootstrap.shdownloads the wheel and its attestation bundle from the release, refuses a file whose SHA256 differs, checks the provenance offline with cosign against the workflow's identity onmain(the check the addons role makes on the add-on images), hands the file to uv through--find-links, and rewrites thepyyamlline of the requirements file to that SHA256, so--require-hashesstill decides what is installed; the pins are still read as data, never sourced. A built wheel is not byte-identical from one build to the next, so the value is always the hash of the exact asset on the release, never one recomputed locally. Five new cases inscripts/lab-host-bootstrap-ansible-core.test.mjsrun the block with stand-ins for uv, curl and cosign: the happy path, the empty pin, a wrong SHA256, a failed attestation, and aPYYAML_VERSIONthat disagrees with thepyyamlline. -
The daily DNS audit now recognises Cloudflare renewing its own certificate (#1421). On 2026-10-26 the audit opened a security issue for 12 changes in
hybridcloudworks.com: TXT records at_acme-challenge.hybridcloudworks.comcreated in pairs and deleted minutes later, by an actor carryingtype: systemand nothing else, with no request method and no user agent; the same pattern came back on 10-27 and 10-30. That is Cloudflare validating the zone's universal certificate, which renews from 30 days before expiry (Certificate validity periods): the zone is a full setup, and for one Cloudflare "handles DCV automatically on your behalf using a TXT record" (Changing DCV method; the name is the_acme-challenge.<domain>of its TXT method), and its audit-log reference defines asystemactor as changes made automatically by Cloudflare systems. A fifth allowlist entry,cloudflare-dcvinscripts/cloudflare-dns-audit.mjs, allows exactly that shape and nothing wider: aTXTnamed_acme-challenge.<apex>, created or deleted by an actor whose only field istype: system. A token cannot take that shape, because a token's request is authenticated and lands asaccountoruserwithcontext: api_token; a challenge forwwwor a lab name, an update, or asystemactor on any other record still reads UNEXPECTED, and the tests say so for each. The report names such an actorCloudflare (system, no token)instead ofsystem unknown. The runbook's allowlist table has the row. -
The Health page shows the content agent, the day's platform jobs, and says when a read could not be made (#1491). Owner request of 2026-11-01 after the Koda work landed. The ops snapshot carries an
agentblock (Koda's key present, active, last used and rotated, never its hint or hash; the switches that are off; the last day's refusals by reason, from the content-freeagent-content-denialaudit rows) andoperationalSignals.jobs24h(the last day's jobs by status). Overview gains a Content agent (Koda) card beside Operational Signals, which now shows the jobs strip; Checks gains a Content agent probe under Platform, recorded by the pulse and held to the same verdict on both sides bystatusParity.test.js. Two honesty fixes ride along: a failed read of the jobs container used to look like "no failed jobs" and now says it could not look, and the Publishing Operations card shows the scheduler's own status word and recorded error, and explains that all-zero counts mean nothing in Approved had come due rather than a broken card (only after a run that completed; a run that broke shows its error instead). Every denial row the guards write now also carries its kind asactionand its time astimestamp, the two paths the audit container indexes, so the Health read is an index seek and not a scan of ninety days of audit rows. - A Cost page under Platform: what the platform costs this month, and whether it will go over (owner request 2026-11-01, ADR 0039).
/admin/cost, for editors, has five tabs. Overview shows the month at a glance (Azure against its budgets with the end-of-month estimate, AI spend, subscriptions per month, the alerts count, the last twelve months) and when each source was last read, with a Refresh. Azure shows a card per subscription or resource group with a budget: spend so far, Azure's own forecast, the daily line and the top services, read from Cost Management with the Function App's managed identity (functions/src/lib/cost/azure.js). AI shows the usage ledger's estimate for the month by provider and by task, ElevenLabs' credits left, the image budget and the Foundry budget. Services is a ledger the owner keeps of the subscriptions paid outside Azure (price, cycle, renewal date, auto-renew), with one-click suggestions for vendors the site already uses. Alerts lists every cost alert with its rule and holds the settings. The figures come from one snapshot (admin_config/cost_snapshot) rebuilt every six hours by the armedcheckAgentHealthtimer and on Refresh (at most once in five minutes); the ledger (admin_config/cost_ledger) is read live. Warning and critical alerts also go to the dashboard's Decision Center (a newcostsource), to Telegram once each, and a monthly closing digest goes to Telegram on the first; renewals appear on the Calendar as a newrenewalkind. Koda can read the page under its Platform switch and change nothing. Seven routes undercms/cost/(functions/src/functions/cost-http.js); Terraform adds theCOST_SCOPESapp setting. The read needs the built-in Cost Management Reader role on the application and Platform Management subscriptions, which the owner grants once by hand (runbook), because the Terraform identity creates no role assignment at subscription scope. - Every draft answer carries an edit link and a preview link (owner request 2026-11-01, #1477). The Drafts routes, on the admin API and on Koda's agent API alike, now put
editUrl(the Drafts page with that draft open,/admin/drafts?draft=<id>) andpreviewUrl(the public preview page with a signed token that expires after 72 hours, ornullwhile the preview secret is not seeded) on every draft, in the list and alone (functions/src/lib/cms/drafts-handlers.js). The preview route now shows drafts on the desk and in review, not only content past review; the Drafts page opens the draft a link names, and shows the preview link beside the open draft. -
Koda reaches the admin sections the owner named, creating and reviewing but never deleting (#1477, ADR 0038). The owner's instruction of 2026-11-01: Koda will be creating content and should have the Pipeline, Enhanced, Spotlight and Second Brain sections and the Platform health and integration reads, with no deletion. The same key now also passes the admin role guard for an allow-list of about a hundred routes (
functions/src/lib/agent-content/admin-access.js), each under one of five new switches on the Koda tab. ADELETEnever passes; publishing, scheduling, unpublishing, slugs, settings, secrets, admins and every section the owner did not name stay closed; the routes that could reach publishing through their body are limited (a status change topublishedorarchived, apublish-contentjob, a content item born published, a book or chapter archived are refused), and the routes that unpublish as a side effect (request inspection, reset review state, the episode review) are not open; in the Second Brain the key acts as the owner so its notes land in the owner's workspace. Every refusal is an audit row with the reason. The tab andGET …/agent-content/v1/statuslist the routes per section. The agent API's ownDELETE drafts/{id}route and its Delete drafts switch are removed: the key never deletes. The runbook's new part 7 is the matrix. -
An hourly workflow removes the firewall windows a dead run left open, and puts the host storage account back to Deny (#1463). A "window" is a temporary allow rule for a workflow's own runner:
ci-deploy-scm-<run id>,ci-smoke-<run id>andci-manifest-<run id>on the Function App or its SCM site, and one IP rule on the host storage account with its default action flipped to Allow. Each is closed by analways()step, which does not run when the runner is lost or the job is force-terminated. The hourly monitor has named a leftover since #1050; nowremove-ci-access-windows.ymlrunsscripts/remove-ci-access-windows.mjshalf an hour after it: aci-deploy-scm-,ci-smoke-orci-manifest-rule whose run has ended is removed, one whose run is still in progress is left alone, and any otherci-*rule is left for a person. The storage account's IP rules carry no names, and Terraform declares an operator's own rule and anAllowrollback that look just like a leftover, so the deploy's open step now writes aciwindowtag on the account (the run id and the runner's address) before it adds the rule, and its close step clears it last; the remover restores only a window whose tag names a run that has ended, doing what that run's close step would have (Deny, that one address, the tag), reads the tag again before each write and stops if a new deploy has taken the window, and reports an open firewall with no tag without touching it. It signs in as the manifest-window identity, not the deploy identity, because the deploy identity trusts only theproductionenvironment's subject and that environment's required reviewers would hold an hourly job every hour. The Function App half needs nothing new; the storage half needs a new custom role, HCW Storage Firewall Writer (infra/roles/storage-firewall-writer.json: the account's read and write actions, nolistKeys), which the owner creates once, and its assignment ininfra/oidc.tf, declared inscripts/assert-expected-plan.mjs. The lookup and the assignment are gated on a new workspace variable,storage_firewall_remover_enabled(defaultfalse), because a lookup of a role that does not exist did not fail the plan on 2026-11-04, it hung HCP Terraform's one plan slot with every other run queued behind it; the owner flips it after creating the role. Unit tests hold each decision, including a rule whose run is still running, and a workflow test runs the step underbash -eto prove the exit code reaches the job summary. -
The Health page says whether last night's Cosmos export ran and what it covered (#1409). The nightly export writes its run manifest into the private
cosmos-exportblob container, which refuses every workstation, and the log workspace has dropped the 03:00 UTC window before (#1530), so the owner could not see whether a run completed or carriedbrain_recordswithout the portal. The functions worker, which has the identity and the network path, now reads the run records and manifests of the last seven UTC dates (functions/src/lib/backup/backup-status.js, names and counts only, cached ten minutes, no new timer) and the ops snapshot carries abackupsblock: the newest completed run, its mode and times, its containers with record counts, how many authored containers are in it and which are missing, andbrain_recordson its own with its partition key/ownerId. Overview shows it under Operational Signals; Checks gains a Nightly export probe under Platform (critical when the newest completed run is older than 26 hours or an authored container is missing; "not known", never healthy, when the manifests could not be read), recorded by the pulse and held to one verdict with the page bystatusParity.test.js. The Cosmos restore runbook says how to read the tile. - A browser journey drives the Second Brain outline-to-draft handoff, and the journeys now run in CI (#1408).
frontend/e2e/second-brain.spec.jsgained a desktop and mobile journey that signs in through the stubbed identity, selects a made-up idea, asks for an outline and checks the request names that idea, shows two citations that link to their own sources and one missing-evidence item, fills the editor through Review as a new note, saves the note with the sources as its links, and creates one draft: a failed attempt and its retry carry the same request id, and the button is disabled once a draft exists. It found that a citation link clicked inside the page opened nothing (the page followed its?item=link once per visit), which is fixed inSecondBrainPage.jsxwith a unit test. The spec had never been in thee2e (playwright)job's list, soci.ymlnow runs it, and the runbook says which of the ticket's lines the journeys cover and which still need the owner's eyes on the real answers.
Session continuity¶
- Two review notes from #1459 are answered (#1455).
.claude/CLAUDE.mdnow names the two separate controls that stop a session approving its own pull request: GitHub's rule that an author cannot approve, and CodeRabbit'sallow_author_approval: false, which makes it ignore@coderabbitai approvefrom the author. It also records what cleared #1460: a fresh CodeRabbit review did not replace its old request for changes and CodeRabbit refusesresolvefrom the author, so the owner dismisses such a review, with a link to where CodeRabbit withdrew it, and a session does not.MEMORY.md's header now says that #1346 left its later list because #1461 closed it. -
.claude/CLAUDE.mdnow says what the merge rules really require, and holds the standing rules that were only in session notes (#1455). The bullet that said theDefaultruleset (the repository's merge rules) requires CodeRabbit's approving review is rewritten as "CodeRabbit's approval is the merge signal; its request for changes is a gate": read from the API on 2026-10-31, the ruleset requires no approving review (required_approving_review_count: 0,require_last_push_approval: false, unchanged since 2026-10-20), only resolved conversations, the 15 required checks on an up-to-date head, and no deleting or force-pushingmain; waiting for CodeRabbit's approval is the repository's practice. A CodeRabbit review that asks for changes still blocks the merge until a newer review replaces it, even once every thread is resolved (the merge API refused #1460 with1 review requesting changes by reviewers with write access), so the bullet says to ask for@coderabbitai reviewrather than dismiss the review. The option F history of 2026-10-18 (#972) is kept in brief, and making the approval a gate again is left to the owner on #1455. A new section, "Lines a session does not cross", adds the rules that were written only in the rootMEMORY.mdand earlier private notes: never print or ask for a secret, no real tenant, subscription or application id in a public file, an image-publishing tag needs the owner's yes and a green dry run on the exact commit, no admin bypass or force-push tomain, and noterraform applyor Azure delete unless asked; it points to the rules the file already had (safety-check refusals, merging your own pull requests, CodeRabbit's boxes, one pull request at a time on the same lines) instead of repeating them.MEMORY.mdnow points to.claude/CLAUDE.mdfor those rules instead of listing them, and the comments in.coderabbit.yamlno longer call CodeRabbit's approval the ruleset's required review (comments only; no setting changed). It also records the owner's instruction of 2026-10-31 to call out every discrepancy a session notices (a repository, file or setting that differs from its siblings, the documentation or the ticket), with its link, whether it looks deliberate, and the fix. It also warns that the worktrees agents use share onegit stashlist, so agents set work aside with a temporary commit instead (two agents' uncommitted changes were swapped that way on 2026-10-31 and recovered), and that files in the shared scratchpad get task-unique names. -
Root travel handoff and completed-work cleanup (#1450). Adds a dated
MEMORY.mdwith merged work, verified application deployments, workflow behavior, and resume commands. Permits that exact root filename and includes it in redaction checks on future edits. Removes generated local artifacts and old task files, deletes local branches verified as merged (including squash merges), and prunes remote/worktree references while retaining installed tools and dependencies. -
The root
MEMORY.mdnow also carries the infrastructure, CI and add-on state (#1450). A second session wrote the section "Infrastructure, CI and add-on work" into the same file instead of adding another one: what was merged today with links, what to do next in order, the dates to remember, and the few things only the owner can do (each with its command and what a good result looks like). The file is still scanned by the redaction gate, so it holds no identifiers, local paths or private repository names.
Fixed¶
- minimatch moved to 11.0.4 in
frontend,functionsandscriptsby an npm override, closing Dependabot's three medium alerts for GHSA-g3hv-75wg-m249 (globparts amplification) as far as they can be closed. No consumer (eslint 10.14,@eslint/config-array,@typescript-eslint/typescript-estree) asks for the fixed major yet, so the override carries it; lint and the full test suites pass in all three packages on it. The one copy that cannot move iseslint-plugin-jsx-a11y's minimatch 3.1.5, kept by the scoped override from #220 because the plugin imports a default export that minimatch 7 and later do not have; that copy has none of the code the advisory describes (the preprocessing it exploits arrived with minimatch 7), so the frontend alert, if it stays open on that copy, is one for the owner to dismiss as not affected. - The Docker Scout workflow test no longer pins
docker/scout-actionto one commit, so Dependabot's bumps of it stay green (#1502). The test held the action's commit as a literal and went red on the first bump; it now checks the shape, a full commit with the release as its comment, and the workflow's prose comment no longer repeats a version Dependabot cannot edit. - The waiting-to-go-live tile no longer shows "0h" over an item it cannot date, "Published, Not Live (6 h+)" is counted instead of sampled, and the Decision Center words the expected secrets the way the Health page does (#1516). After #1513 shipped, the Operational Signals tile read 0h over "1 approved or published, not live, not scheduled": its ordered read takes only items with a string
updatedAt, and the one waiting item has none. The snapshot now also reads the waiting items without one (up to 50) and ages them by the first time they carry, Firestore-style{ seconds }times included; when items wait and none carries any time, the tile says no recorded time instead of 0 h and the card turns Degraded, as a read that could not count does. An emptyupdatedAtno longer sorts first and hides the real oldest item. The publishing watchdog now filters for published, not live and unscheduled in the query (absentLiveincluded) and reads up to 1,000 candidates, so its count is exact below that and shown with a "+" at it (on the alert too); it used to read 250 of every non-live item in no order. Its overdue-schedule read now also treats an item with noLivefield as not live, like the new read. The Decision Center listsAZURE_SPEECH_KEY,CLOUDFLARE_ANALYTICS_TOKENandCODER_BROKER_TOKENas "unresolved on purpose, waiting on you", low priority, with each one's reason, and any other unresolved reference as a high-priority fault. The Telegram/queuereply says when items wait with no time rather than "Nothing is currently stuck in staging". - The Cost page's budget bar and budget alerts count this month's spend even when Azure's budget figure lags. On 2026-11-03 the Application subscription's card showed
site-monthly-budgetat "0% used" while the cost query had USD 7.33 of its USD 150 (about 5 %): Azure updates the spend it keeps on a budget on its own schedule, and the bar and the 50/75/90 % alerts both read that figure, so they would have warned late. When a scope's cost query is read, the budget's spend is now the higher of Azure's figure and the queried month (functions/src/lib/cost/azure.jswithSpendAtLeast), and the Azure tab says so under the bar when the queried figure is the one shown. Azure's own forecast is unchanged. - The Cost page shows each subscription its own budget, not a resource group's. On 2026-11-03 the Application subscription's card showed
site-foundry-monthly-budget($75), the Microsoft Foundry resource group's budget, with the group's spend as the subscription's. Listing a subscription's budgets also returns the budgets of its resource groups, and the reader took the first by name. It now keeps only the budgets set on the scope itself, told apart by each budget's id (functions/src/lib/cost/azure.jssummarizeBudgets), so the subscription showssite-monthly-budgetand the Overview's Azure total and budget add up the right budgets. - The Cost page shows each subscription's month from its budget when the cost query is throttled, and asks for last month by its dates. With each failing read now named (2026-11-03), the Azure tab showed two causes. Last month's total was refused with
400 BadRequestwhen asked with theTheLastMonthshortcut, so it is now asked with aCustomperiod of last month's dates and granularityNone, the form the cost analysis measured September with. Both subscriptions' cost queries were still throttled (HTTP 429), and the budget list, read after them, was never reached. The budget list now comes first (it is the Consumption API, which the Query API's allowance does not cover), and when the cost query is refused the card shows the budget's own month-to-date spend and forecast with a note instead of nothing (functions/src/lib/cost/azure.js). A code that only repeats the status ("HTTP 429 429") is no longer shown twice. The runbook records the 400 and explains the new note. - The Cost page says which Azure read failed and with what code, and one refused read no longer blanks a card. After the rate-limit fix went live on 2026-11-03, the Azure tab said only "Azure answered HTTP 400" for every scope, which named neither the read nor the reason. Each refusal now names the read (the budgets list, this month's costs, or last month's total) and Azure's own error code when it is code-shaped; Azure's sentence is still never copied (
functions/src/lib/cost/azure.jsdescribeArmStatus,azureErrorCode). This month's costs are read first, and if the budgets list or last month's total is refused the card still shows the month with a note saying what could not be read. The runbook's table has a row for the 400. - The Cost page reads Azure within Cost Management's rate limits, and keeps its last good figures when it cannot. On 2026-11-03, right after the owner granted the role, every scope answered HTTP 429: Cost Management allows each Azure directory 12 query units every 10 seconds, 60 a minute and 600 an hour, shared with Cost Analysis in the portal (Microsoft Learn), and a refresh sent all six of its queries at once. The reader (
functions/src/lib/cost/azure.js) now asks one question at a time, waits as long as Azure'sqpu-retry-afterheader says (at most 30 seconds) and tries once more, asks for last month's total only once a month, and, when a refresh is still refused, keeps this month's last good figures with a note saying when they were read (an info alert says so too). The Azure tab no longer tells the owner to grant the role when the reason is something else; it points to the runbook, whose table now explains the 429. - The Health page stops calling the three expected unresolved secrets Critical, and its two "staged" numbers say what they count (#1512). The list of Key Vault references that are unresolved on purpose (today the fallback speech key, the Cloudflare analytics token and the Coder broker token, each with its reason) moved from the nightly monitor script into
functions/src/lib/expected-unresolved.js, and the monitor imports it from there, so the two read one list. The ops snapshot now splits unresolved names intoexpectedUnresolved(with reasons) andunexpectedUnresolved; Pipeline Readiness lists the expected ones under Unresolved on purpose, each with its reason, and both Key Vault probes name them as "unresolved on purpose, waiting on the owner"; the card, the Runtime config chip and the probes stay Critical, by name on the card and the probes, for anything off the list. The live probe states its one assumption: it reads a count, not names, from the anonymous endpoint. The public/api/healthcount is unchanged. On Operational Signals, the oldest-staged tile was the oldest of 50 items read in no order and then filtered for Live, so it showed whichever old item happened to be in the sample; it now reads the oldest Approved or Published item that is not live and not scheduled for later, ordered by its last edit (only rows with a date take part in the order), with the count waiting, under the name Oldest Waiting to Go Live. The Publishing card's Staged Too Long is renamed Published, Not Live (6 h+), which is what the watchdog counts. - The Cost page draws before its first reading instead of showing "This page hit an error". Right after the deploy of 2026-11-01, before the Azure and AI figures had ever been read (or while Azure cannot be read), the summary carried no AI month and no Azure totals, and the Overview, Azure and AI tabs read both without checking, so the page stopped with
Cannot read properties of null (reading 'monthToDate'). The server now always answers a zero-filled AI month (functions/src/lib/cost/snapshot.js), and the page fills in empty figures itself (frontend/src/lib/cost.jswithEmptyFigures), so it also works against the server already deployed. A new test renders every tab with the server's real answers for those two cases, generated from the backend's own code; it fails with the same error without the fix. - The Cost page runbook's one-time grant now works for an account in several directories, and says when it fails (runbook). On 2026-11-02 the owner's first run signed in to the account's home directory, not the one holding the production subscriptions, so Azure refused both grants with
InvalidAuthenticationTokenTenant, yet the command still printeddonefor each, because it printed that unconditionally. A new step signs in to the directory that owns the application subscription, looked up at run time so no directory id is written down, and the grant line printsFAILEDwhen Azure refuses a step and stops when the app's identity cannot be read. With both fixes the owner's run granted Cost Management Reader on both subscriptions. - The Health page counts an unresolved Key Vault reference once, not twice. On 2026-11-01 the Pipeline Readiness card and the public
/api/healthcount read 6 for 3 references: App Service puts every app setting into the process twice, asNAMEand asAPPSETTING_NAME, andsecrets-health.jscounted both. It now leaves out theAPPSETTING_twin when the plain name is present. The three that remain are unresolved on purpose (the fallback speech key, the Cloudflare analytics token and the Coder broker token, all waiting on the owner). In the same change the expected-unresolved list inscripts/check-unresolved-secrets.mjsdrops the two Podbean values and the StreamYard key, which the owner has seeded and the monitor was reporting as resolving-but-still-listed, and the Terraform comments beside those three settings say so. The edge and DNS runbook's health step, which still calledunresolvedSecrets: 2the healthy reading, now points at the list for the number. - The restore-drill check opens its issue again, and the Key Vault check says when it could not tell. These are two more workflows with the same flaw the delivery monitor had (the entry below). A workflow step with no
shell:runs asbash -e, which stops at the first failing command, so a line such ascode=$?written after a script never ran when the script failed. Incheck-drill-age.yml(the Monday check that the Cosmos restore drill is at most 100 days old), an overdue drill or an unreadable Drills table ended the step before it recorded the result, so the steps that open the "Cosmos restore drill overdue" issue and fail the run with the reason were skipped. That happened on 2026-10-12 (run 38279975200): the drill was overdue and no issue was opened. The drill was run on 2026-10-13 and stays current through 2027-01-21, so nothing is due now; the first Monday check that would open the issue is 2027-01-25, and it now will. The step also writes a short summary of its own if the script ever crashes before writing one, because a missing summary file would skip the issue in the same way. Inmonitor-unresolved-secrets.yml(the check that the Function App's Key Vault references resolve), a payload the script did not understand ended the step with code 2 before it printed the lines saying the check could not tell; the run was red either way. Both now start the code at 0 and read it ascommand || code=$?, which-edoes not stop.scripts/workflow-exit-code-steps.test.mjsruns both steps underbash -ewith stand-ins fornodeandaz, and scans every workflow for the old shape, so a new step cannot bring it back. It failed on the old steps before the fix, and run against the workflows as they were before #1488 it also finds the delivery monitor's two steps. - The delivery monitor prints its report on a red run, not only on a green one. In
monitor-deploy-drift.ymlthe steps "Measure the drift" and "Read the run history" captured the script's report withreport=$(node ...)and read the exit code withstatus=$?on the next line. Neither step setsshell:, so GitHub runs it asbash -e {0}, and under-e(stop at the first failing command;set -uo pipefaildoes not turn it off) a failing capture ends the step on that line. So when the script found drift (exit 1) or could not read (exit 2), the step stopped before it printed anything, and the log and the step summary were blank in exactly the runs that went red: runs 41219040554, 41238281521, 41258066344, 41272679445 and 41325654390 each go from the env block straight toProcess completed with exit code 1., while green run 41306150963 prints the table. Both steps now setstatus=0and capture withreport=$(...) || status=$?, which-edoes not cut short (the shapeci.ymlandmonitor-functions-registered.ymlalready use), so the report is printed and the job still ends red. The newscripts/monitor-deploy-drift-steps.test.mjstakes each step's shell out of the workflow and runs it withbash -eagainst a stand-innodethat exits 0, 1 and 2, checking the report reaches both the log and the summary and the exit code is kept; it also proves the old shape printed nothing.check-workflow-health.invocation.test.mjsnow pins the new line instead of the oldstatus=$?. - Koda's key is read in the shapes connectors send (#1477). Muse's first calls on 2026-11-01 carried a working key and were answered 401, because the Agent Content API read the key only from
Authorization: Bearer <key>. The guard (functions/src/lib/agent-content/guard.js) now also reads it as the bareAuthorizationvalue and from anX-API-KeyorX-Agent-Keyheader, never from the address;Authorizationwins when both are present, and a value that is not shaped like a site key is still refused without an audit row. The runbook, ADR 0037 and the API contract say so. - The lab agent guard's refusal record no longer becomes an unhandled rejection when Cosmos refuses the write (#1476). The guard that checks the lab agent's token writes one row to
admin_audit_logsfor every request it refuses, and calls that writer without waiting for it, so a write refused under throttling (Cosmos answering 429) rejected with nothing handling it; Node treats that as fatal by default.default-agent-guard.jsnow writes throughwriteDenialAudit, the shared sink the admin and workspace guards got on #1465, which awaits the write inside its own try/catch and logs one content-free line (the error's code, never its message); the row also carriesactionandtimestamp, the two names the container indexes (infra/cosmos-containers.json), like every other guard's row. The guard's 401 and 403 answers are unchanged. A newdefault-agent-guard-denial-audit.test.jsdrives the real guard and real wiring with a plain-function Cosmos double (avi.fn()spy attaches its own handler and hides the rejection), listens forunhandledRejection, and failed on the old code with the rejected write in its list. Every otherupsertDocaudit writer infunctions/src/lib/awaits its write, either inside a try/catch or where a failure becomes the handler's own 500; the PR lists them. - Visitor lab sessions: the last follow-ups from the two reviews are done, and the door is declared in Terraform (#1400, #1403).
LABS_VISITOR_SESSIONS_ENABLED, the switch that opens the visitor lab sessions, was read byfunctions/src/lib/labs/visitor-session.jsand declared by nothing ininfra/, so a copy set by hand would have been removed by the next apply.infra/functionapp.tfnow sets it from the new variablelabs_visitor_sessions_enabled(infra/variables.tf,falseby default: the door stays closed, as an absent setting kept it, until the owner sets the variable in thehcw-azureworkspace),scripts/assert-expected-plan.mjsdeclares the setting appearing as"false", and a test infunctions/src/lib/secret-catalog.test.jsties the name the code reads to the declaration and its default. The sweep (reapExpiredVisitorSessions) now stops taking up sessions 260 seconds after it started (SWEEP_DEADLINE_MS): the claim it puts on a session lasts 5 minutes from the run's start, and ten slow sessions could have outlasted it, so a second run could have taken a session over while the first was still working on it and spent a try on a second, harmless delete. What is left waits for the next run, and the log says how many. The three queries onlab_jobsare bounded withTOP(the sweep reads no more than the 10 sessions it will take up; the status and start routes read at most 100 records), with a parameter, as Microsoft Learn's "Parameterized queries" page shows it; the test store'srunCosmosQuery(functions/test/cosmos-predicate.js) readsTOPthe same way and refuses one that is not a whole number above zero. The remainder of item 2 (a user create that got no answer, and a session that could not be saved after both Coder calls worked) and the wording point onenabled_timersindocs/standards/required-inputs.mdwere already onmainfrom #1464 and #1422, checked against the code and the tests rather than assumed; the Coder page for the user routes was read again on 2026-11-03 and still says{user}is "User ID, name, or me" for the delete. Item 4 of #1400 (a session belongs to an address, not to a secret) is accepted as it is, because the lab is free, time-boxed to an hour and holds nothing of the visitor's; the two ideas that wait (a circuit breaker while Coder is down, an alert on the sweep's Warning lines) are recorded on #1403 with why. Documentation: the lab host runbook ("Visitor lab sessions"), Required inputs §4.1 and §4.5, Variables and secrets, ADR 0032 (amendment of 2026-11-03). Terraform: a plan that adds one app setting, closed; the apply is the owner's in HCP Terraform, and nothing here was applied or run against Azure, Cosmos or Coder. The live check of #1400's item 5 (the deployed app refusing a request that did not come through Cloudflare, and the Cloudflare rule itself) stays the owner's:az functionapp config appsettings list -g rg-web-site-prod-cus -n func-site-prod-cus-01 -o json | ConvertFrom-Json | Where-Object name -in 'NODE_ENV','CF_ORIGIN_SECRET' | Select-Object namelists the two settings by name only. - The Cosmos request log is filtered at ingestion again, by a rule Azure actually runs, so the Log Analytics workspace stops hitting its daily cap every evening (#1530). The filter PLAT-8 (#968) wrote was a
transformKqlproperty on theCDBDataPlaneRequeststable, and the Tables API has no such property: Azure kept the retention it was sent beside and dropped the filter without a word, so the table carried every read and write on every container, the workspace hit its 0.25 GB cap between 20:30 and 21:50 UTC every evening from at least 2026-10-27, and nothing was logged until 08:00 UTC, the 03:00 UTC Cosmos export's completion event included (which is whyalert-cosmos-export-dailyfired every night from 2026-10-29). The filter now lives where Azure runs one from: a data collection rule of kindWorkspaceTransforms(dcr-logs-transforms-prod-cus,infra/observability.tf), named as the workspace's default rule (infra/main.tf). Both are azurerm resources that read their state back, so a rule edited in the portal or a link removed shows in the next plan, which the azapi write never did. The table's retention moves toazurerm_log_analytics_workspace_table, which reads it back too. The filter keeps every request onambassador,doc_vaultandbrain_records(the personal and private data), every write on every other container, and drops reads of ordinary content and everything onleasesandbrain_search, the change-feed bookkeeping and the hourly-rebuilt search index: measured over seven days on 2026-11-03, 95% of the table's volume (9.48 MB an hour in the hours before the cap, 0.48 kept). The runbooks and ADRs 0022 and 0028 say so instead of describing the filter that never ran.
Changed¶
- The Second Brain validation record carries the restore drill of 2026-11-04 (#1409).
brain_recordswas restored with Azure Cosmos DB point-in-time restore into a new, isolated account (cosmos-site-sbx-cus, deleted afterwards), its 84 documents matched the production count, a selected note matched its own revision chain,brain_searchwas rebuilt there from the restored notes plus made-up fixtures, and search and backlinks answered through the site's own code; 36 minutes to a verified restore.docs/architecture/second-brain-validation.mdrecords the counts, the Learn pages and what was not verified: the nightly export manifest read (line 1 of the issue) still waits for the functions deployment that carries the Health page's Nightly export tile. - CI: the frontend unit suite and the browser suites each run as two shards, so a frontend pull request no longer waits on one runner for either (#1545). On the frontend-only #1557 the
frontendrow took 280 s (192 s of it the vitest run with coverage) ande2e (playwright)247 s (195 s the browser suites), 302 s from push to all-green against the issue's target of under 240 s, with no placeholder job left to remove. A shard is one part of a suite run on its own runner beside the other part. The unit suite left thefrontendrow (which keeps lint, format, route validation and the build) for afrontend-unitjob of two shards: each runsnpm run test:shard -- --shard=N/2(a newfrontend/package.jsonscript), which writes its half's results and coverage as a blob report and checks no threshold, because vitest checks a shard's coverage like any other run's and half a suite is always under the ratchet (shard 1 of 2 measured 48% of lines against 75.5%); afrontend-unit-mergejob downloads both blobs and runsnpm run test:merge(vitest run --merge-reports --coverage), where the thresholds infrontend/vitest.config.jsare checked once on the whole suite and, after a merge tomain, the mergedlcov.infois whatcoverage (qlty)uploads. The browser suites run as two Playwright shards of thee2ejob, each with its own build, preview server and failure report.gateneeds every new job, and a skipped shard counts as skipped.scripts/ci-workflow-steps.test.mjsholds the shape; the required checks runbook has the section "The two slow suites run as shards". - CI: the verify matrix is built from the path filter, so a row whose files did not change starts no runner (#1545). The five rows of the
verifyjob (frontend,functions (azure),scripts (operations),vps-agent,edge (availability probe)) each started a runner on every pull request and, when their files had not changed, exited after 8 to 13 seconds, because a matrix row cannot carry anif:of its own (five such rows on the docs-only #1555). Thechangesjob in.github/workflows/ci.ymlnow decides each row's filter in a detect step of its own and builds the matrix as JSON in a last step, "Build the verify matrix", which theverifyjob reads withfromJSON; a row left out is never created. Thescripts (operations)row is always created, because three of its steps run on every pull request whatever changed, and its answer travels asmatrix.relevantto decide its lint and tests. The filters moved with their comments and are unchanged;scripts/version-floors.jsonreads the functions row's Node.js 24 line from its new place.scripts/ci-workflow-steps.test.mjsruns the five detect steps and the build step with bash and checks the matrix for a docs-only change (the scripts row alone), a frontend change and a push against the same answer worked out in JavaScript;docs/runbooks/required-checks.mddescribes it. - Lab image: the Azure CLI moved to 2.92.0 and the alz provider to 0.22.1, and the CLI's
pipoverride went (#1519). The weekly scan's bump was red because Azure CLI 2.92.0 now ships pip 26.2.2 itself, the versionAZURE_CLI_PYTHON_OVERRIDESinlab-image/versions.envpinned, and the full build refuses an override the CLI already carries. That line is deleted; certifi, pyjwt and urllib3 stay, the CLI still ships older ones. The alz checksum was read again from the Terraform registry and matches the bot's reading. - The lab host's PyYAML pin moves to the CPython 3.15 wheel (#1505). #1468 moved the lab image and the lab host to CPython 3.15.2 and merged before #1508, so
mainbriefly pinned the cp314 wheel's SHA256 on a 3.15 host, whichbootstrap.shwould have refused at the hash check (the host names thepyyaml-6.0.3-cp315release from its own Python line, and that asset is another file).PYYAML_WHEEL_SHA256inlab-image/versions.envis now the wheel the firstpublish-pyyaml-wheel.ymlrun onmainafter that move attached to the pyyaml-6.0.3-cp315 release (run 41557242736), verified by hand withgh attestation verify --bundle --cert-identityagainst the workflow's identity onmain. No host run happened in between. - The lab host now takes the published PyYAML wheel (#1505).
PYYAML_WHEEL_SHA256inlab-image/versions.envis set to the SHA256 of the wheel the first run ofpublish-pyyaml-wheel.ymlonmainattached to the pyyaml-6.0.3-cp314 release (run 41555864123; the asset verified by hand withgh attestation verify --bundle --cert-identityagainst the workflow's identity onmain). From the nextbootstrap.shrun the host downloads that wheel and its bundle, checks the SHA256 and the provenance with cosign, and installs it in place of PyPI's wheel; the one-line change makes a new generation of the ansible-core environment, which is the first live proof of the host's path. - CodeRabbit reviews are asked for, not automatic (#1487). Owner decision 2026-11-01, on the free plan.
.coderabbit.yamlnow hasreviews.auto_review.enabled: false: every push and every "Update branch" to an open pull request spent one of the five included reviews an hour, and on 2026-11-01 three pull requests used the hour between them while #1485 waited for the review that would lift a stale request for changes. A push or an "Update branch" now starts no review and spends none of the allowance; only an explicit ask does. A session comments@coderabbitai reviewonce the required checks are green on the head it wants read, which works whether or not automatic reviews are on (CodeRabbit: automatic review controls)..claude/CLAUDE.mdsays so in "Each bot's first review is waited for, and CodeRabbit's is asked for" and in the merge-signal bullet; the rest of the review practice (three rounds, every thread answered and resolved, CodeRabbit's approval of the current head as the merge signal) is unchanged. No paid add-on is turned on. - The plan check's declared changes are pruned to what is still pending (#1479).
scripts/assert-expected-plan.mjslists the one-off changes a plan may carry, and the file's own rule is that an entry is deleted once its change has applied. The plan check on the run for1ccc72a(actions run 41200891363) showed about twenty entries as "declared, not in this plan". The ones that infra/ sets unconditionally, so that no diff means applied, are deleted: the Second Brain's two containers and five settings, the Doc Vault container, the Cloudflare analytics settings, the two timer flags catalogued disarmed, the Coder broker token reference, alert-timer-overdue's three added rows (now part of the applied query the file compares against) and the Code Conductor retirement itself, which applied inrun-BN7KzWpfvXom9SFC. The three arming plans, the SSH-alert loopback entries and the Web Analytics site create stay, because each waits on a value the owner sets in the workspace and "not in this plan" cannot tell that from applied. Tests follow: the entry-by-entry cases for the deleted declarations are gone, a prune test pins what left and what stayed, and a new case checks the applied timer rows match the tree while no change to the rule is declared. - AWS Learn catalogue: SAP-C02 and SAP-C03 re-read ahead of their day, so the build stays green on 2026-11-17 (#1497). On that day the catalogue alarm (
frontend/src/data/education-catalogues.test.js, which fails on every branch the day a stored status is overtaken by its own dates) would have named both rows offrontend/src/data/aws/certifications.js. Re-read on 2026-11-03 against AWS's September announcement (September 2026 new offerings, whose Key dates still read "November 16, 2026 – Last day to take SAP-C02 / November 17, 2026 – SAP-C03 GA delivery begins"), the exam page and the hub. SAP-C02 is storedretiredwith its last day kept and SAP-C03activefrom 2026-11-17, each withstatusReadDate2026-11-03, so under rule 2 ofderiveStatusthe badges keep saying "Retiring" and "Coming" until AWS's day and nothing moves early. The SAP-C03 exam guide and the DVA-C03 exam guide resolve now that registration has opened, so both rows link them (they were 404 on 2026-10-05). The header records the pages and wording, and a new test block holds the two rows and the days they turn. - The dependency-review runbook says where the Python add-ons' base-image pull requests come from, and why the website's weekly run does not open them (#1386). That issue recommended that
lab-supply-chain.ymlopen a pull request in each Python add-on when the website's base moves. Before that was built, a read of the add-on repositories on 2026-11-03 found each already carries a dailybase-updateworkflow (added 2026-10-27, the day after the issue) that opens that pull request with the add-on's own App token and has opened real ones that a person then merged (Cloud Assessor #55 and #57), so a second opener here would have opened a competing pull request in each add-on. Nothing is built here; the runbook now records the mechanism, the one move it refuses (a Python minor line, which is why the three add-ons' daily runs have been red since the website moved to 3.15 on 2026-10-31, for example Cloud Assessor run 41597788687) and the read-only check. The paragraph aboveBASE_IMAGEinlab-image/versions.envwas read again against the pinned digest and records that the stale wording the issue named had already gone with #1468. - The plan check forgets the five declared changes the #1530 and #1469 applies consumed (#1530, #1469).
scripts/assert-expected-plan.mjslists the one-off changes an HCP Terraform plan may carry, and its own rule is that an entry is deleted once its change has applied, so a later drift of the same thing is caught instead of waved through by a stale line. Two runs applied on 2026-11-04:run-fQRFPxBy7CowV6zG, for #1535 (the Cosmos request-log filter: the delete of the azapi table resource and the creates of the azurerm table, the workspace transformation rule andalert-logs-overquota), andrun-vUQrQLSGJh4J5HZ2, for #1531 (the Function App's Cosmos data role on thehcwdatabase). Those five entries are gone and the file's running list of what is in state names them with their runs. The tests follow: the #1469 case now holds that the appliedhcwgrant is not declared again while any database key added later must be, and a new case holds that the four #1530 declarations are gone while their three resources are still ininfra/. - CI runs only the jobs a change needs, and one
gatecheck stands for all of them (#1545). Owner reading of 2026-11-04: too many workflows, placeholder jobs, and every merge re-running every other open pull request.ci.ymlnow starts with achangesjob that diffs the pull request once and answers whether the browser suites, the lab-host Ansible checks, the Coder template checks and the Python harness checks are needed; those four jobs read the answer in a job-levelif:and are skipped outright (no runner) when it is no, instead of starting a runner to post their name, as five jobs did on every pull request.e2e (playwright)now runs only whenfrontend/changed. The verify matrix rows keep their in-job detect step, because a matrix row cannot carry anif:of its own. A finalgatejob needs every other job, runs even when some were skipped (if: ${{ !cancelled() }}), and fails when any result isfailureorcancelled; it is the one CI check the ruleset will require, in place of ten single job names, and the ruleset's new list of five (payload,gh apiline, read-back and rollback) is in the new required checks runbook; it takes effect when the ruleset is updated, and until then every old name is still a job name, so pull requests merge under either list. The separate Coverage workflow is gone: thefrontendandfunctions (azure)rows run their suites once, with coverage (the thresholds in eachvitest.config.jsare the ratchet), and acoverage (qlty)job uploads both reports with GitHub OIDC after a merge tomainonly, since a pull request runs only the rows its files touch and a report with one package missing would read as a drop. The Complexity Delta workflow is acomplexity deltajob of CI, unchanged in what it does. CodeQL stays on pull requests until the ruleset no longer requires its three contexts; the merge-queue question is answered in the runbook (not yet, with the reasons).scripts/ci-workflow-steps.test.mjsruns the four moved detect steps with bash as before and holdsgateto needing every job;scripts/ruleset-contexts.test.mjsholds the new list and the old one. ADR 0026 has the addendum. - The plan check forgets the #1469 step 2 delete its apply consumed (#1469).
scripts/assert-expected-plan.mjslists the one-off changes an HCP Terraform plan may carry, and its own rule is that an entry is deleted once its change has applied, so a later drift of the same thing is caught instead of waved through by a stale line.run-DvAMt2zE3XU4es4happlied the merge of #1544 on 2026-11-04 at about 13:55 UTC: the plan was the one declared destroy,azurerm_cosmosdb_sql_role_assignment.func_cosmos(the Function App's account-wide Cosmos Data Contributor grant), beside the standing permanent diff, and the identity now holds the database-scoped grant alone. That entry is gone and the file's running list of what is in state names it with its run. The test follows: the #1469 case now holds that neither the account-wide grant's block ininfra/nor a declaration of its delete exists, where before it held that the delete was declared while the block was gone.
Removed¶
- The Code Conductor brain is taken back out of the site (#1479). The owner's direction of 2026-10-31: the site's Second Brain is for content, ideas and publications, and the engineering brain, the one that holds decision records, belongs to Code Conductor (Proj-Code_Conductor), which is its own product. So the second workspace that #1465 added this morning (the
code-brainmodule and its six routes under/api/cms/code-brain/, the shared-workspace refactor of the creator brain made for it, the export and restore additions, the sync-client script change, the runbook and the contract entries) is reverted. That code did reach production, still switched off: the second attempt of Deploy Functions run 41195200540 shipped0b3576a7, which containsdfac635, finishing at 2026-11-01 05:03 UTC (its first attempt, at 2026-10-31 11:02 UTC, was refused because a Terraform apply was running, and deployed nothing). It stayed live until run 41279867586 deployed14b1f423, which carries this revert, from 06:12 UTC the same day. (An earlier version of this entry said it was never deployed; it was deployed, still switched off.) The creator Second Brain is exactly as it was before #1465 and its tests pass unchanged. ADR 0036 stays as a withdrawn record. The Terraform #1465 added goes too (thehcw_code_braindatabase and container, their variables, the fiveCODE_BRAIN_*Function App settings, the guard test and the plan-assertion entries), because the owner confirmed that the run fordfac635was applied: the next confirmed apply in HCP Terraform destroys that empty database and container, and pressing confirm is the owner's step. The plan check (scripts/assert-expected-plan.mjs) declares those two deletes and the five setting removals, sotfc-plan-check.ymlreads that run as expected. The restore script (scripts/restore-cosmos-export.mjs) now restores only into containers the current spec provisions: a retired container that an older export set still lists is named and skipped, never written into the creator database.
Features¶
-
Koda, the owner's content agent, gets an Agent Content API and a Koda tab on the AI Engine (#1477, ADR 0037; built from the owner's proposal of 2026-10-31). Koda runs in the Muse service and can only sign in with one fixed key, so the site now issues one: made on the new Koda tab (
/admin/ai-engine?tab=koda), shown once for the owner to paste into Muse's secure field, and kept only as a SHA-256 hash in oneadmin_configdocument. The routes under/api/agent-content/v1/(status, the Drafts routes by id, and the published reads) are the Drafts stage's own handlers created with the key guard in the role guard's place, so a draft Koda writes gets the same field allow-list, 256 KB cap, etag protection, title dedup, refusals for anything live and audit rows as one typed on the Drafts page, withkoda (agent)as its author anddraftOrigin: 'agent'. The key is checked in constant time with no cache, limited to 120 calls a minute with the public routes' compare-and-increment counter, refused everywhere under/api/cms/, and every refusal is an audit row without the key. Five switches on the tab (read drafts, create and edit, send to review and back, delete, read published) narrow what the key may do; there is no publish, unpublish, slug, settings or secrets route for it to call. Rotate and Revoke cut the old key on its next request. The base path isagent-content, not the proposal'sagent, because/api/agent/*is the lab agent's. The runbook is Agent Content API: connect Koda, rotate and revoke; the open point for the owner is the host Muse registers (api-azure.hybridcloudworks.com, where the API answers). -
Blogs hub and editable source intake (#1443). Blogs now sits directly below Coder Corner with article review/edit links, source search/provider filters, editable RSS and listing sources, pause/resume, bounded intake limits, per-source manual jobs and run status, and gallery/URL fallback images. Adds Kiro’s official RSS feed to AWS. Both scheduled intake paths honor runtime settings; stable cache identities, conflict detection, audited editor-only saves, untrusted custom sources, and guarded RSS downloads preserve the existing publishing workflow. Inspection prefers article/social images over source defaults; listing links now resolve relative to their page path, and listing ingestion supplies the article-ID generator missing from production wiring. See the blog workflow.
-
The visitor session broker's Coder token,
CODER_BROKER_TOKEN, is now declared in Terraform as a Key Vault reference to the secretCODER-BROKER-TOKEN, the way every other credential the Function App reads is (the declaration half of arming the sweep timer; follow-up to item 1 of #1403) (pull request). The code had readCODER_BROKER_TOKENsince the visitor sessions were built, but nothing ininfra/declared it, so a copy set by hand would have been removed by the next apply, and switching the sweep timer on would have done nothing useful.infra/functionapp.tfgains the reference.functions/src/lib/secret-catalog.jslists it, so the Integrations → Keys page offers it in the Labs group, andfunctions/src/lib/credentials/register.jsregisters it as a hand-rotated credential with no rotation rule yet.scripts/check-unresolved-secrets.mjslists it as expected-unresolved until the owner seeds it (the cap in its test moves from 5 to 6), andscripts/assert-expected-plan.mjsdeclares the new app setting sotfc-plan-checkreads the plan asDECLARED. While the secret does not exist the app behaves as it did before: the broker reads the token as not set, so a session cannot start (the visitor routes answerVISITOR_NOT_CONFIGUREDonceLABS_VISITOR_SESSIONS_ENABLEDis on and Turnstile is seeded), and the timer logs one Warning and calls nothing; applying the reference first is safe because Terraform writes only the reference and never reads the vault. New tests tie the setting names the code reads (CODER_BROKER_TOKEN_SETTING,TURNSTILE_SECRET_SETTING) to the references ininfra/and fail when the broker's reference is removed. Documentation: Required inputs §4.1 (theenabled_timersrow now says plainly that the timer keeps cleaning up existing sessions whileLABS_VISITOR_SESSIONS_ENABLEDis off, the wording point CodeRabbit raised on #1412), §4.5 and §4.6; Variables and secrets (32 references); the lab host runbook ("Arming it" step 1 and the new "Seeding the broker token", with how to take it away again); ADR 0032's amendment; the cold rebuild list; the rotation schedule and the compromise tiers. Still open in #1403 and not done here: which Coder user and permissions the token is made with (item 4), andLABS_VISITOR_SESSIONS_ENABLED, whichinfra/still does not declare. Nothing was applied or run against Azure, HCP Terraform or Coder. - A timer,
sweepVisitorSessions, runs the visitor lab session sweep every two minutes on its own, so a Coder clean-up that Coder has not confirmed no longer waits for the next visitor (item 1 of #1403; the owner answeredtimer) (pull request).reapExpiredVisitorSessionsends visitor lab sessions past their hour and retries the clean-ups of the workspace and user that Coder has not yet confirmed (#1388). It ran only when a visitor read the lab's status or started a session, so with no visitors, or withLABS_VISITOR_SESSIONS_ENABLEDoff (the status route answers before it sweeps), a waiting session, and the Coder user and host workspace slot it held, waited. The new timer (functions/src/functions/visitor-session-sweep.js, one run infunctions/src/lib/timers/visitor-session-sweep.js) calls the same sweep with the real store,fetchandreadBrokerConfig. Why every two minutes: a refused clean-up is tried again after 2, 4, 6 and 8 minutes, a dead claim is taken over after 5 and a session lasts 60, so two minutes puts each retry within one tick of being due (five minutes would turn the 2-minute retry into a 5-minute one; one minute would double the runs, because a visitor's own request already runs the sweep at most once a minute). A run with nothing to do is three queries onlab_jobsand no call to Coder, about 2,160 queries a day; request-unit cost was not measured against the live account. It is bounded: at most 10 sessions a run (MAX_SWEEP_SESSIONS) and a 10-second limit on each Coder call. It never creates a session or a Coder user: it is given only the query, read and conditional-replace operations, a test watches every operation that would create or overwrite a document, and the only thing it asks Coder to build is a workspace delete. It runs whateverLABS_VISITOR_SESSIONS_ENABLEDsays, because that switch closes the door to new visitors and turning it off while sessions exist must not strand their Coder users and workspaces. It does nothing at all, not even a query, when Coder is not set up (CODER_URLandCODER_BROKER_TOKEN;readBrokerConfigno longer falls back to the read-only status token, which cannot delete anything): the sweep would otherwise spend one of a waiting session's five tries on every tick, and five tries end a session ascleanup_failed. It logs counts and kinds only (reaped,finished,pending,failed; the kind of an error; never a session, a Coder id, or any text Coder or Cosmos sent), as a Warning when the run did something and as Information otherwise (host.jsonships only Warning), and it never throws out of the handler: a failed run is one Warning line and the next tick tries again. Because a run that cannot sweep still counts as a success toalert-timer-overdue, the runbook says to look for that Warning. The trade-off: before the timer, tries were spent only when a visitor arrived; now they are spent on the clock (at about 0, 2, 6, 12 and 20 minutes, a retry slipping a tick when a run starts late), so a Coder or token outage longer than about twenty to thirty minutes leaves the waiting sessions ascleanup_failed, for a person to finish by hand. No alert reads those lines yet; that is in #1403. Two runs at once cannot double-call Coder: the Functions host runs one copy of a timer across all instances and does not start it while a run is still going (Microsoft Learn), and, without relying on that, the sweep claims a session with a conditional write before it calls Coder; tests run two timer runs, and a timer run with a visitor's sweep, at the same moment and count the Coder calls. One limit: a claim lasts 5 minutes from the start of a sweep, and ten sessions whose Coder calls all run to their 10-second limit can use all of it, so a claim taken over then can lead to a second, harmless delete (Coder answers 404 or 410 for what is gone) that spends one try; the margin is an open item in #1403. The sweep in the visitors' requests stays, with its once-a-minute limit, as the only sweep while the timer is off. Hardened in the same change, because the timer runs unattended with the broker's token:readBrokerConfigno longer falls back to the read-only status token (every create and delete with it would be refused, and each refused clean-up try is spent), so withoutCODER_BROKER_TOKENthe broker is not configured; an id or name saved in a session record is written into a Coder URL only when it is plainly an id (not.., nothing with a slash,?,#or%, and notme, which Coder reads as the caller's own account); a record's ownendStatusand try count are read only when they are one of the two final statuses and a whole number; and a log line names an error only by its status, system code or class. Infrastructure (plan only, nothing applied from here): the flagSWEEP_VISITOR_SESSIONSjoinslocal.timer_catalogue(infra/functionapp.tf) and theenabled_timersallowlist (infra/variables.tf), so the Function App gainsFEATURE_FLAG_SWEEP_VISITOR_SESSIONS = "false", off until the owner adds the name toenabled_timers;sweepVisitorSessionsjoinslocal.timer_schedules(infra/observability.tf), soalert-timer-overduewatches it; andscripts/assert-expected-plan.mjsdeclares both changes for the plan check, in each state the alert's timer list can be in. Registered infunctions/src/functions/index.js,function-inventory.json, the registration-shim list and the route inventory (now 29 timers). The runbook (docs/runbooks/labs-host.md, "The sweep timer") says what to check, how to arm it, and how to turn it off or roll it back; ADR 0032 (a dated amendment),.azure/api-surface.jsonanddocs/standards/required-inputs.mdsay it too.docs/standards/required-inputs.mdalso records thatCODER_BROKER_TOKENis declared nowhere ininfra/(MISSING), so a copy set by hand would be removed by the next apply: declaring it is its own change, tracked in #1403. Tests fail on the tree without the timer (6 of the 7 timer test files), on the lab sweep code as it was before the hardening (25 tests), and on 38 hand-made changes, all caught, among them: the timer not registered, the gate inverted, the schedule changed, an error let out of the handler,createDochanded to the sweep, the master switch ignored, the sweep waiting for the visitor-session switch, a run that logs the broker token or the error text, a run that sweeps with no Coder settings or with only the status token, a path that takes..orme, and each of the infrastructure and plan-check edits left out. What is still open in #1403 (items 2 to 9, and the new items found while building this: a time-based try budget, an alert on the Warning lines, the lease measured from the start of a sweep) stays there. Not live until the owner has merged, applied the Terraform run, deployed the functions, declaredCODER_BROKER_TOKENin Terraform (it is not today) and addedSWEEP_VISITOR_SESSIONStoenabled_timers; nothing here was applied or run against live Azure, Coder or Cosmos. - Cloud Crawler's catalogue row is
available, so/tools/cloud-crawlerframes the tool once the site is deployed (the second of two pull requests for #1357; the first was #1411). The host has run thev0.2.0image since the owner's playbook run of 2026-10-26 (the playbook is the script that sets up the lab host), and the site's status address then answeredconfigured: true, reachable: true. The row lets the tool's pane (the framed window where it runs) save files (downloads) and copy its report (clipboard-write), and does not let it ask the page to move (navigate); the pane's code, read on 2026-10-26, never asks to (#1354 is the rule). The tool's address joins the site's frame policy (frame-src) and the clipboard and full-screen lists ofPermissions-Policyinfrontend/staticwebapp.config.json, as for the other three tools. The row's wording drops "or your Terraform" and theterraformtechnology entry, because the engine the lab runs cannot read Terraform yet; put both back when the add-on ships an engine that does (tracked in HCW-CloudCrawler_AddOn#29; HCW-CloudCrawler_App#60 is merged, but the App has no release afterv0.1.0). Smaller fixes: a test title and an API-surface note that still said the setting was not provisioned; thescripts (operations)CI job now also runs whenfunctions/src/lib/addons/registry.jschanges, whichscripts/assert-expected-plan.test.mjsreads; and the plan check's entry forADDON_CLOUD_CRAWLER_URLis deleted now that the setting is applied. The pair page, the program README, the root README, the labs host record, the Scout runbook and the integration standard now say four tools,TODO.mdloses its "one AddOn row iscoming" row, and the labs host runbook gains "Cloud Crawler: the first real upload". The add-on's integration guide is updated in its own repository (HCW-CloudCrawler_AddOn#30) so its copy of the row equals this one field by field. Not verified by any session: the tool inside the site's frame in a real browser, and one real upload with the human check.lab-host/is untouched on purpose (a merge there makes the site ask for a playbook run). - The dossier's wording check (OSS-9) now reads every Markdown file,
NOTICE,.env.exampleand the issue forms of a repository, not onlyREADME.mdandCLAUDE.md(#1405, for HCW-NetworkEyes_App#318, item F-2).docs/standards/oss-docker-app-dossier.mdhas a new one-line PowerShell command that takes the newest commit onmain, lists that commit's files through GitHub's tree call, reads each of those documents at the same commit and prints a line for each hit of "customer", "client", "pricing" or "edition". It stops with an error instead of printing a partial list if a file cannot be read or GitHub cut the file list off (CodeRabbit's review of the pull request). The rule now says what to keep (technical terms, dated history, a quote of another party), to write down the commit id the command printed, and when OSS-9 is met. The template line and the Network Eyes worked-example row are updated to match. The command was run, copied out of the page, in PowerShell 7.6.7 and Windows PowerShell 5.1 against Network Eyes on 2026-10-26 (233 hits in 14 files onmainat 19759c6a). The row says "part" until HCW-NetworkEyes_App#320, which rewords the documents and adds a guard test, merges. - The lab host is ready to run Cloud Crawler and the site's backend is ready to reach it; the catalogue row stays
coming(first of two pull requests for #1357; this one is #1411).lab-host/ansible/group_vars/all.ymlgains thecloud-crawlerrow:docker.io/hybridcloudworks/img-cc-lab-addonv0.2.0, the first image with the upload route, pinned by its index digestsha256:5a43c90bda9bed1d3472847f492b7c05405e6a6ce2d4aa7f4d9b519bb72daf28, on loopback port 18084, 512 MiB, 256 processes, the add-on'sCC_environment (the same frame-ancestors, site-origins, proxy and limit settings as the other three rows, the widget's public site key, andCC_MAX_STORE_MBat its documented 64), andTURNSTILE_SECRETfromvault_addon_cloud_crawler_turnstile_secret. The digest was read on 2026-10-26 from Docker Hub's public tag API and fromdocker buildx imagetools inspect, which agreed;gh attestation verifyagainstHCW-CloudCrawler_AddOnexited 0 and the signing certificate namespublish-images.ymlatrefs/tags/v0.2.0(run 40318349978), which the row'sattestation_identity_regexpmatches.infra/functionapp.tfdeclaresADDON_CLOUD_CRAWLER_URL, andscripts/assert-expected-plan.mjsdeclares it as exactly one added app setting, as #1089 did for the other two; three new tests inscripts/assert-expected-plan.test.mjshold the declaration to the Terraform value, to a plan that adds that one setting and nothing else, and everyADDON_*_URLname ininfra/to a name the Function App's registry reads. The vault-key table inlab-host/README.mdand the runbook's list of keys set by hand now include the key (seven become eight; the vault holds sixteen keys, not fifteen), and the table gains the two earlier add-on keys it had omitted. The runbook's new "Cloud Crawler: the host steps" gives the owner the Terraform confirmation, a names-only vault check, the playbook run and the status check, in order, with what to see at each. The pair page, the app-to-addon README,TODO.md, the labs-host architecture page and the scan runbooks' add-on counts (three become four) follow. Not changed, and unverified: the catalogue row,frame-srcand the add-on's integration guide (the second pull request, after the status route answersconfigured: true, reachable: true); whether the vault key is on the host (the owner states it is; no session can read the host's vault); whether the container starts and the certificate answers for the name (the host has not run the row; the wildcard name resolves, read on 2026-10-26); and that this host has memory for a fourth 512 MiB container beside five workspaces and Coder (ADR 0035 notes the ceiling). - Second Brain is implemented inside HCW admin (#1395). The owner-only workspace connects private Markdown notes, revisions, topics/projects, creator references, search and a keyboard-accessible map. Existing editors can capture research and create reviewed drafts with private provenance. An on-demand Foundry assistant cites workspace sources. The Windows Obsidian companion uses a dedicated
brain_syncclient, encrypted token storage and recoverable conflicts. Private records join authored backups; indexes can be rebuilt. Deployment starts with both feature flags off, with owner configuration and real-vault enrollment governed by the rollout runbook. - The website now counts the lab sessions it starts for visitors, and the Labs hub Dashboard has a card that shows the count (editors and admins only; nothing public). Each time the visitor session broker creates a new session (
functions/src/lib/labs/visitor-session.js), it adds one to a per-lab, per-UTC-day tally (functions/src/lib/labs/lab-starts.js): onelab-starts:YYYY-MM-DDdocument{ day, total, byLab }and onelab-starts:metamarker{ since }in thesystemcontainer, each written only if nobody else changed it since it was read (an ETag check), so two overlapping starts do not lose a count. The counting documents hold nothing that identifies a person (no IP address, client key, session id, user id, token, query string or time of day), andlab-starts.test.jsfails if a stored document gains any other key. A repeat request that returns a live session, a page view, a refused request and a failed provision are not counted; a counter fault is logged by code only and never reaches the visitor. New editor routeGET cms/labs/starts(cmsLabsStarts) returns today, the last 7 days, the last 30 days, the total and a per-lab breakdown; the card shows them with the day counting began, says in plain words that it counts sessions and not people, and has Copy as text for a letter, which writes one plain sentence with the real numbers and the date range (no vendor names). Counting is on from the deploy: the visitor session records are deleted about two hours after their last update, so there is no earlier history and none is estimated. Signed-in member starts inside the lab pane and the public validation runs are not visitor sessions and are not counted. The broker's lab check now uses an own-property test, so ids such asconstructorortoStringare refused instead of passing.docs/runbooks/labs-host.mdhas the new "Counting lab starts" section. Not live until the functions and the frontend are deployed. - A standard and a fill-in dossier say what an open-source Docker app from Hybrid Cloud Works must look like to qualify for Docker's free open-source programme (DSOS), with Network Eyes as the worked example (ticket #678).
docs/standards/oss-docker-app-dossier.mdquotes Docker's four rules from its programme and application pages (read 2026-10-25) and says what the pages leave out: whether an organisation that already pays for a Docker plan can be accepted, which the page tells the reader to ask Docker in writing. It lists thirteen checks (OSS-1 to OSS-13: public source and images, an OSI-approved licence with the same SPDX id inLICENSE, the Dockerfile label,NOTICEand the README, the copyright holder, OIDC-only publishing with provenance, an SBOM and a Scout check, theimg-<code>-<lab-addon|appliance-app>name, no paid edition and no customer or client wording, active development, community files, the two rulesets, and Dependabot, CodeQL and dependency review), each with a PowerShell command and the result to expect, a template block to fill for the next product, and a short paragraph on moving a product to its own Docker organisation if it ever gets a paid edition. The Network Eyes example uses values read from the repository, Docker Hub and the registry on 2026-10-25 and 26. It records what HCW-NetworkEyes_App#316 fixed while the page was being written (the copyright holder inLICENSEandNOTICE, and the "client" and "customer" wording) and the gaps that remain onmain: "lab edition" wording in the README andCLAUDE.md, no licence label on the lab add-on image, a stored Docker secret that no workflow uses, and a tag ruleset that lists the repository-admin role as a bypass actor. The page is registered inmkdocs.ymland linked from section 2.11 of the field application standard. No guard script was added, because this repository has no existing test that checks standards pages. Nothing was sent to Docker. - A test now fails any pull request that puts a draft article back into
docs/content(PR 4 of the move of pre-publish drafts out of the repository; owner rule 2026-10-25, follows #1381).functions/test/docs-content-guard.test.jslistsdocs/content(sub-folders and letter case included) and fails if anyblog-*.mdfile other thanblog-template.mdandblog-machine.mdis there; its failure message explains in plain words that the repository and the docs site are public and that drafts belong in the owner's private vault and on the admin Drafts page. Thefunctions (azure)row ofci.ymlnow watches all ofdocs/content/instead of the two contract files, so the guard also runs on a pull request that only adds such a file..claude/CLAUDE.mdgains "Pre-publish content never goes in this public repository", and the "why the notes stay outside" paragraph ofdocs/runbooks/obsidian-idea-notes.mdnames the guard. Checked by adding adocs/content/blog-test.md: the test failed with that message, then passed once the file was removed. - The 17 pre-publish drafts are removed from the public repository and the docs site, together with the "Import from docs/content" route that read them (PR 3 of the move of pre-publish drafts out of the repository; owner rule 2026-10-25, follows #1379 and the promote-script change). Every
docs/content/blog-*.mddraft except the two contracts (blog-template.md,blog-machine.md) is deleted, as are theirmkdocs.ymlnavigation lines and five matching wiki-redirect entries; each file was first compared byte for byte with its copy in the owner's private vault. Four of them (blog-how-to-01,blog-how-to-02,blog-build-log-01,blog-build-log-02) were linked from the docs front page's "Start here"; those links are gone and the section now points only at the naming convention, so they can be restored from the vault if the owner wants.POST /api/cms/drafts/import-repo, its handler,drafts-import.js, the admin "Import from docs/content" button with its hook and result list, the client timeout entry, the.azure/api-surface.jsonrow, thecmsDraftsImportRepoentry infunctions/function-inventory.jsonand the narrowedci.ymlpath filter go with it;route-inventory.test.jsnow asserts the route is absent.repo-draft.js(the parser behind the markdown upload) and the "From docs/content" label for rows already in the database stay;repo-draft-source.jsstays as the pinned GitHub fetch that the lab-host drift check and the delivery monitor use, without its draft listing and file reads. Tests that read real draft files now read synthetic fixtures infunctions/src/lib/cms/__fixtures__/, written to the blog template, and the test that pinned "seventeen articles" is deleted.docs/content/blog-machine.mdanddocs/runbooks/obsidian-idea-notes.mdno longer tell anyone to import fromdocs/content. After this deploys, the docs page/content/blog-lab-01-landing-zone/returns 404 andPOST /api/cms/drafts/import-reporeturns 404 instead of 401. - The idea-note promote script now saves a draft into the owner's private vault, never into the public repository (PR 2 of the move of pre-publish drafts out of the repository; owner rule 2026-10-25, follows #1379).
scripts/ideas/promote-idea.mjswrotedocs/content/blog-<name>.mdinto the working tree; it now writes<vault>/Drafts/blog-<name>.md, where the vault comes from--vault <folder>or theHCW_VAULTenvironment variable, and stops with a plain-language error (exit 2) when neither is given or the vault folder does not exist.--out-diris removed. It refuses (exit 1, nothing written) any output folder that resolves inside the repository, whether given as a relative path, with.., in a different case on Windows or macOS, or through a symlink or junction (links are followed with the real path, and the comparison runs again after the folder is made). The file format is unchanged, andfunctions/src/lib/cms/idea-promotion.test.jsstill reads the output withparseRepoDraftand now also withparseMarkdownDraft, the reader behind the Admin → Drafts upload. New tests cover a path inside the repository, relative paths,..tricks, a junction and symlink into the repository, a link that points elsewhere, a missing vault andHCW_VAULT, and assert that no test run leaves a file underdocs/content(they fail against the old script).docs/runbooks/obsidian-idea-notes.mdis rewritten so the steps are promote into the vaultDraftsfolder, then upload on Admin → Drafts, with every instruction that sent a draft through a pull request removed and the reason explained; the Drafts stage section ofdocs/content/blog-machine.mdnow says the Drafts page is the source of truth and the import-from-repository route is being retired. Not removed here (a separate change): the 17 existingdocs/contentdrafts, their mkdocs entries and the import route. - Admin → Drafts can take a whole markdown file, or several at once, straight from the owner's private vault, so a pre-publish draft no longer has to be committed to the public repository (PR 1 of the move; owner decision 2026-10-25).
POST cms/drafts/from-markdowntakes{ markdown, origin }(originisvaultorpaste) at the editor role, reads the text with the docs/content import's own parser (parseRepoDraft, which needs no repository path, so the import's path checks are untouched), checks it with the rules a typed draft goes through (validateDraftFields, 256 KB body cap, 272 KB for the whole file, the request body counted as it is read and abandoned past 545 KB, with or without a Content-Length) and writes adrafting, never-public draft withdraftOriginvaultorpaste(functions/src/lib/cms/drafts-markdown.js,drafts-handlers.js). The document id comes from the normalised title, so a second upload of the same article, even a double click, answersALREADY_IMPORTED(with the stage it is at) instead of duplicating, and a title a published article already carries is skipped; a file that is not a draft is a 422 with the parser's reason. The markdown is never logged and never put in an audit row. The Drafts page gains a Paste or upload a markdown draft panel: a picker for many.mdfiles sent one request at a time with a result line per file (created, already present, or the reason it was not added; one bad file does not stop the rest) and a closing total, plus a box for one pasted draft; the list labels drafts From my vault and Pasted in. Registered in the route inventory,function-inventory.json,.azure/api-surface.jsonand the client timeout table. The docs/content import is unchanged and stays until a later change retires it.docs/runbooks/obsidian-idea-notes.mdand the Drafts stage section ofdocs/content/blog-machine.mddescribe the new path. Not yet live: the owner deploys the functions and the frontend, and the admin sign-in path is not exercised by tests. - The owner's standing orders for sessions are written into the project instructions: the session runs the work and is the owner's last gate, not the first.
.claude/CLAUDE.mdgains "You run the work; the owner is the last gate, not the first" (do every ticket you can, a missing permission is asked for and never a reason to hand work back, GitHub administration is the session's, use the agents, the board is the list of work, naming conventions, and the link hook). Written after the owner repeated these in one day (2026-10-25). - An add-on's integration guide is checked against the website's catalogue by a reusable workflow, so a guide that has drifted fails a job instead of waiting for a review (website half of #1356).
scripts/addons/check-guide-drift.mjs(Node built-ins only, tested byscripts/addons/check-guide-drift.test.mjs) reads the add-on's row fromfrontend/src/data/addons/catalogue.js, the row pasted in the guide's "Catalogue row" block (evaluated in an emptynode:vmcontext) and, when the guide states one, the port fromlab-host/ansible/group_vars/all.yml, and fails naming each of twelve fields (and the port) that differ with both values; it also fails when the site no longer lists the id or the guide has no readable row..github/workflows/addon-guide-check.ymlis theworkflow_callwrapper:permissions: {}withcontents: readon the job, no secret, every action pinned to a commit, the script fetched at the workflow's own commit (job.workflow_sha) and the data frommain, the result in the job summary;scripts/addon-guide-check-workflow.test.mjsholds it to that.docs/runbooks/addon-guide-drift.mdhas the caller each add-on repository adds and how to read a failure. The add-on repositories' callers are still to be added, so the issue stays open. - One shared standard for the four field application repositories is written down, with a conformance table and the gaps as proposed tickets (PF-2, HCW-NetworkEyes_App#255).
docs/standards/hcw-field-application-standard.mdlists what NetworkEyes, CloudCrawler, CloudAssessor and MigrateOrchestrator must, should and may do (identity, documentation, community files, labels and the board, rulesets, the self-merging pull request action, CI, Dependabot, security scanning, releases, container images, secrets, agent instructions), with the evidence for each row read from the default branches, the settings API and arepository-admindry run on 2026-10-25, a per-repository conformance table, seven recorded exceptions and sixteen proposed tickets. Findings that need the owner soon: NetworkEyes'repository-adminworkflow lists 7 required checks while the live ruleset has 10, so applyingprotect-mainwould drop three; NetworkEyes' CodeQL default setup scans only the workflows, not its Python or TypeScript; and no repository has theP0orseverity:criticallabels the board offers. - The Scout runbook records that the copyleft policy is left as it is (#1367).
docs/runbooks/docker-scout.mdsaid loosening it was still open; the owner decided on 2026-10-25 to leave it, and the page now says so with a link to the decision. - Two standing rules for sessions: every message links what it names, and the specialist agents are used for review and audit work.
.claude/CLAUDE.mdgains "Everything named to the owner carries its link" (each message links every issue, setting, decision or file it names, in the same sentence, with a check before sending; owner instruction of 2026-10-25) and "Use the specialist agents" (which agent for which kind of review, how to brief one, and to say in the closing summary which was used). The wording was reviewed by the documentation archivist agent before this change; its nine wording findings were applied. - The lab host's migration add-on moves to
v0.3.2(digestsha256:bd3e0a16…), and only the new repository name is accepted for its attestation (#1367). The add-on'sv0.3.2release was published to Docker Hub on 2026-10-25 andgh attestation verifyagainstHCW-MigrateOrchestrator_AddOnpassed (signer: itspublish-images.ymlatrefs/tags/v0.3.2).lab-host/ansible/group_vars/all.ymlnow pins that digest and the attestation pattern no longer accepts the old repository name, as its own comment said to do once the pin moved;scripts/lab-host-addons-attestation.test.mjsnow asserts the old name is refused (shown failing against the old pattern). Not yet live: the owner runs the lab host bootstrap after the merge. - How the nine repositories' dependencies are kept under watch is written down (#1367).
docs/runbooks/dependency-review.mdrecords, per repository, which tools actually run (read from each repository's workflow files on 2026-10-25, not guessed from file names), a one-line weekly check of open Dependabot and code-scanning alerts, a proposed rule for how fast to act on each severity, and the open findings, including the Network Eyes app's missing review gates and the Migrate Orchestrator app's 17 code-scanning alerts, which belong to the apps' workstream. The Scout runbook no longer tells the reader to widen the token on an entitlement error: it checks the Docker ID and token first. - Docker Scout runs in CI, with one stored Docker token (#1367). The owner decided on 2026-10-25 to store a Docker credential for it after both token-free ways failed (
docker/scout-actionand the Scout command line each needed a signed-in Docker user)..github/workflows/addon-image-scout.ymlreads the three add-on digests the lab host runs (scripts/lab/read-addon-pins.mjs) and, for each, prints Scout's policy table and fails on a CRITICAL or HIGH vulnerability that has a fix, when a pull request changes the pins and every Wednesday. The credential is the repository variableDOCKER_SCOUT_USERand the secretDOCKER_SCOUT_PAT(a personal access token with the lowest permission, Repo Public Read-only), read in two places only; every job holdscontents: read; a fork's pull request is skipped, not handed the token; a missing token is a warning on a pull request and a red run on the weekly one, never a quiet pass.scripts/addon-image-scout-workflow.test.mjspins each of those; seven deliberate breaks of the workflow were each caught, and the file was restored byte for byte.docs/runbooks/docker-scout.mdhas the steps to create, store and rotate the token. The weekly Trivy scan stays as the check that needs no credential and opens the issue. - The weekly vulnerability scan now covers the three add-on images, and the way to use Docker Scout is written down (#1367).
lab-supply-chain.ymlscanned only the two lab images with Trivy; its scan step now also reads the digests the lab host runs fromaddons[]inlab-host/ansible/group_vars/all.yml(scripts/lab/read-addon-pins.mjs, tested byscripts/read-addon-pins.test.mjs, andscripts/lab-pin-pr-workflows.test.mjspins that the scan includes them and refuses an empty read) and scans those too, opening the same weekly issue when a HIGH or CRITICAL vulnerability has a fix. No new workflow, no third copy of the Trivy pin, no stored credential. Scout itself is not in CI: bothdocker/scout-actionv1.27.0 and the Scout command line, tried on 2026-10-25, stopped ("user githubactions not entitled to use Docker Scout" and "Log in with your Docker ID"), because Scout only answers a signed-in Docker user, and storing a Docker token for it is the owner's decision. An earlier draft of this change claimed Scout needed no login; that was wrong (it had run on a desktop where Docker Desktop is signed in) and is corrected.docs/runbooks/docker-scout.mdsays what the plan includes (Docker Team, unlimited Scout-enabled repositories), the four checks and which is for what, why Scout is not in CI and what adding it would take, how to turn Scout on, the desktop commands, how to read the result, and the Docker Hub housekeeping facts. The decisions that remain are on #1367. - The labs host's Applied state records the run of 2026-10-25 (#1355). Read over SSH:
/etc/hcw/applied-commit.jsonsays the host converged at 2026-10-25T04:44:25Z fromd2b207b4, which wasmain's head, anddocker psshows the migration, network assessment and cloud assessment add-onsUp (healthy)on theimg-mo-lab-addon,img-ne-lab-addonandimg-ca-lab-addonimages. The ADR 0035 row and the Configuration row ofdocs/architecture/labs-host.mdnow say so, with how each fact was read, in place of "not recorded" and the stale 2026-10-14 commit. - The three tool add-ons' images follow the owner's naming convention
img-<short name>-<job>-<type>.lab-host/ansible/group_vars/all.ymlnow pullsdocker.io/hybridcloudworks/img-mo-lab-addon(migration),img-ne-lab-addon(network assessment) andimg-ca-lab-addon(cloud assessment), and the tag is the full release tag (v0.3.1,v0.1.0,v0.1.0). The digests are unchanged: the owner copied each image and its signed build attestation to the new repository on 2026-10-25, and the registry shows the same index digest and the attestation on every new name. The container names (hcw-addon-<id>) are not changed. The oldhcw-addon-*repositories stay until the host has run from the new names. The runbooks, the host README and the app-to-addon pages follow. - The migration add-on's repository is now
HCW-MigrateOrchestrator_AddOn, and this repository's living references follow. The owner renamed it on 2026-10-24 fromHCW-AzMigrateOrchestrator_Addon(its App repository had already becomeHCW-MigrateOrchestrator_App). The three links in the catalogue, the integrations directory and the admin service registry, the add-on pair pages, the integration standard, the lab-host comments and the bootloader header now use the new names; GitHub redirects the old ones. The architecture decision records anddocs/architecture/app-to-addon/final-package.mdkeep the names that were true when they were written. The lab host's attestation pattern for the migration image accepts both names, from #1362, until the pin moves to an image built under the new name. - The migration add-on's image attestation pattern accepts both repository names, ahead of its rename. The owner is renaming
HCW-AzMigrateOrchestrator_AddontoHCW-MigrateOrchestrator_AddOn(the App repository is alreadyHCW-MigrateOrchestrator_App). The lab host verifies each add-on image's build attestation against a pattern that names the building repository, and an attestation cannot be edited afterwards: the digest pinned today was built under the old name, and the first image built after the rename will carry the new one.lab-host/ansible/group_vars/all.ymlnow acceptsHCW-(AzMigrateOrchestrator_Addon|MigrateOrchestrator_AddOn)for the same workflow and tag refs only, andscripts/lab-host-addons-attestation.test.mjspins that a branch ref or a lookalike name (...-fork) is still refused. The old name comes out of the pattern once the pin has moved to an image attested under the new one. - The notes DNS record applied (#1350), so the plan checker forgets it. #1360's create applied on 2026-10-24 (
run-bb1XtRGJEbuK1Cec;tfc-plan-checkread it as applied, carrying the known permanent diff and nothing else).notes.hybridcloudworks.comthen resolved to Cloudflare addresses on 1.1.1.1 and answered HTTPS withServer: cloudflareand aCF-RAY, status 404 because the name is not yet claimed in Obsidian; there was no redirect loop and no 525 or 526, so the zone's SSL/TLS mode suits the record. One correction to #1360's description: it expected the plan to read 1 to add, 0 to change, 0 to destroy, but the run's own counts were 4/1/3, which is the permanent diff (the counts are not the check, the addresses are) plus that one create. - notes.hybridcloudworks.com, prepared for Obsidian Publish (#1350).
infra/frontend.tfdeclarescloudflare_dns_record.notes_obsidian_publish: a proxied CNAME fromnotestopublish-main.obsidian.md, which is what Obsidian's Custom domains page asks for (read 2026-10-24): proxy on, and the zone's SSL/TLS mode "Full". It is the zone's second proxied hostname afterapi-azure, because Cloudflare, not Obsidian, presents the certificate for the name. The plan checker declares the one create, anddocs/runbooks/edge-dns-verification.mdgains the row, the count (seven records) and what a redirect loop or a 525/526 means.terraform fmt -checkandterraform validatepass and the checker's 66 tests pass; nothing is applied. The owner confirms the HCP Terraform run ofhcw-azure(expected plan: 1 to add, 0 to change, 0 to destroy), then claims the name in Obsidian. - Add-on panes get the site's theme, and the network and cloud assessments are
granted what they asked for (#1353, #1354). Owner decisions 2026-10-24, from
the owner's phone. Theme: the frame now loads
<origin>/?theme=darkor?theme=light(addonFrameUrl), read from thedarkclass on the document when the page first renders. It is read once and never updated, because changing a frame's address reloads it and would drop the report open in the pane; a toggle reaches an open pane at the next page load. Checked in a real browser, framed by the live site on 2026-10-24: the network assessment and the cloud assessment follow the parameter (dark turns the page dark, light keeps it light, with the browser itself set to light); the migration add-on ignores it and stays white, which is an AddOn-side gap. Capabilities: the owner said yes to the guides' request. The rows now holddownloadsand a newclipboard-writecapability, replacingnavigateanddownloads. The site'sclipboardcapability grants read and write (allowFor), which the issue text had understated, and the AddOns only need write for "Copy report", so a write-only capability was added instead of grantingclipboard;navigategoes because neither add-on sends one. The standard (sections 8 and the helper list), the two pair pages and the accepted-risk row inTODO.mdsay so. Seven tests were written or changed; with the old code put back they fail, with the new code all 76 pass. Not checked: the live panes after the next deploy, and whether "Copy report" works inside the sandboxed frame. - Cloud Crawler joins the add-on suite as a
comingrow, and the suite's documents are brought in line with the live site. Owner request 2026-10-24 ("introduce the rest of the add-ons … the site needs to know what backend integrations"). The fourth pair,HCW-CloudCrawler_AppandHCW-CloudCrawler_AddOn, gets a catalogue row (cloud-crawler,comingsince 2026-10-24), a server registry entry (ADDON_CLOUD_CRAWLER_URL, unset, so the status route answers{ configured: false }with no read and no network, proven by a new test), the route/tools/cloud-crawlerand its explainer page, and a pair page,docs/architecture/app-to-addon/cloud-crawler.md. It is not in the Tools menu, has no CSP entry, no app setting, nogroup_varsrow and no port: the App has not tagged a release and the AddOn is a skeleton with no image (read 2026-10-24). Public copy names nothing behind the site. Checked on a production build in real Chromium, dark and light: the page shows the title, a "Coming soon" badge, the summary and the reason, frames nothing, has no accessibility violation (axe, whole document), and no link to it appears on/azure. The documents that had drifted since the network and cloud assessments wentavailableon 2026-10-12 (#1089) are corrected: the program's index now lists four pairs with their real status and adds a table of the backend each add-on is configured to use (Coder for the network assessment; human verification for all; nothing else), read fromgroup_vars; the network and cloud pair pages are rewritten asavailable;migration.mdno longer quotes an image digest, which had gone stale (digests are written down only ingroup_vars);labs-host.mdno longer calls ports 18082 and 18083 reserved; theREADME.mdno longer says the network and cloud assessments are "on the way"; and theTODO.mdaccepted-risk row aboutcomingrows and[VERIFY]values is replaced by one about Cloud Crawler. Not changed, and unverified:labs-host.md's "Applied state" table still says the add-on role is not yet on the host, while the live status route shows all three add-ons reachable; only the host's own record (/etc/hcw/applied-commit.json) settles it. - Idea notes: an Obsidian second brain for content, with a format, a check and
a promote-to-draft script (#1343). Owner request 2026-10-24. The vault lives
on the owner's computer, outside this public repository; the scripts take its
folder as an argument and only ever read it. An idea note is a note whose
properties are
type,theme,formatandstatus(and optionally title, subtitle, tags, reading, part, date).scripts/ideas/check-idea-notes.mjs <folder>reports what is wrong with each note and a count by status and type.scripts/ideas/promote-idea.mjs <note>turns anoutlinedblog idea whose format is how-to or build-log intodocs/content/blog-<name>.md, in the shape the admin's Import from docs/content takes; it never overwrites, never edits the note, and refuses what can only be private notes or only work in Obsidian ([[wikilinks]],%% comments %%, Dataview and Tasks blocks). The properties are read by a small reader of our own, with no new dependency, because Obsidian writes lists in the block form and the import's reader takes only the inline form. Runbook:docs/runbooks/obsidian-idea-notes.md, written for someone who has never used Obsidian. Checked: 36 scripts tests on temporary vaults (never a real one);functions/src/lib/cms/idea-promotion.test.jsreads each promoted draft back with the import's own parser (parseRepoDraft) and holds the format's limits and content types to the import's and the publisher's, seen failing when either was changed; the runbook's steps run once in PowerShell against a temporary vault, with the outputs it shows.ci.yml'sfunctionsfilter now namesscripts/ideas/idea-notes.mjs. Not done: an admin screen for the backlog; promoting types other than blog; any look at the owner's real vault. - A sample interview, so the Interviews pages show up and there is a file to
copy. Owner request 2026-10-24.
frontend/src/data/interviews/example-interview.jsonis a made-up guest ("Sample Guest") whose page, bio and subtitle all say it is a demonstration and that no real person said any of it. It adds the Interviews section to the Azure landing page, the list page and/azure/interviews/example-interview, and puts the first two in the sitemap. Delete it when the first real interview is published (docs/runbooks/add-an-interview.md). Checked on a production build served locally: the page is prerendered with an Article record and appears insitemap.xml; in real Chromium at 1280 px in dark and light, axe found no violations on the article, every book spread and the list page, with no console errors; the book offers no button on a 390 px phone or with reduced motion;/aws/interviewsand an unknown interview are not-found pages. Not checked: the live site (not deployed), and a screen reader. -
Interviews on the Azure section: a readable article page per interview, with a book view that turns its pages. Owner request 2026-10-24. An interview is one JSON file in
frontend/src/data/interviews/; adding a file adds the interview, with no list to edit, and a file whose name starts with an underscore is a draft the site ignores.docs/runbooks/add-an-interview.mdhas the format, an example and the steps. Each interview gets/azure/interviews/<name>and a card on/azure/interviews. The page is the whole transcript as plain text, with a title, a description, an Article record and a link in the sitemap, and that is what the build pre-renders for search engines, so no separate transcript generator was needed. Book view is an extra the visitor chooses on a wide screen when their device is not set to reduce motion; it is a stack of CSS leaves that turn, with the two buttons beneath it as the way in (the arrow keys, Home and End work when a button has focus), a spoken position ("Pages 3 and 4 of 6"), and every page except the two on show unreachable and hidden from screen readers. No flip-book library: the one the owner's plan named was last released in April 2021, and the owner chose to build the turn here instead. Nothing is published yet: no interview exists, so there is no Interviews card on the Azure landing page, no list page in the sitemap and no pre-rendered route until the first file is added (a first version put the link in the top menu; see the Fixed entry below for why it moved). The build and its tests stop on a bad file with a sentence naming the file and the field. Checked in a real browser, dark and light, wide and phone width, with axe including colour contrast, which found and fixed four things the unit tests could not see: the site theme repainting the book's white pages and its links, a second<main>landmark, and the site's global<header>styling leaking onto the article heading. Obsidian as a second brain for the content creator is a separate piece of work and is not in this change. -
go.hybridcloudworks.comfor the Linkie link-in-bio page. Owner request 2026-10-24. Linkie serves a custom hostname that CNAMEs toapp.linkie.bioand proves ownership with a TXT record, both of which its Domain settings page lists once the name is saved there.infra/frontend.tfnow declares the two records the way the podcast one is: a DNS-only CNAMEgo, and the TXT_cf-custom-hostname.gowith the value Linkie generated. Declared as Terraform records, and not added by hand or through Cloudflare's API, so the daily Cloudflare DNS audit sees them as the workspace's own and the next plan shows no drift. Linkie's page also lists rows named@andwww; it does not say whether those names are relative togoor to the zone, and read as the zone a CNAME at either would hand the site to Linkie, so only what its help article asks of a subdomain is declared: the CNAME and the TXT (an early draft of this entry, the runbook and the Terraform comment stated the zone reading as fact; it is unverified, and the wording now says so). The plan checker declared the two creates and the edge DNS runbook gains both rows and thegochecks. Applied by the owner on 2026-10-24 (HCP Terraform status Success), after which both records answered on 1.1.1.1 and 8.8.8.8 and the site's own apex andwwwwere unchanged. The first browser request got Cloudflare error 1014 "CNAME Cross-User Banned", which Cloudflare's page says is how a cross-account CNAME answers until the target's owner (here Linkie) has registered the hostname with Cloudflare for SaaS. The declared creates are deleted from the plan checker. Linkie's status moves from Failed to Live when it has registeredgo, which its article says can take up to 48 hours. -
A Telegram message when the delivery monitor goes red, and the monitor fires at 8 hours instead of 24. Owner decision 2026-10-23. The "Monitor delivery health" check went red on 2026-10-22 because one line on a frontend page had waited more than a day for its by-hand deploy, and the only notice was GitHub's failure email. Now
checkAgentHealth(the five-minute timer that is already running) reads that workflow's runs from GitHub's public API once an hour and sends one Telegram message titled "DELIVERY MONITOR RED", naming the job that failed and linking the run. A reminder goes again if it is still red a day later, and one "back to green" message follows when a run passes. If the bot cannot read GitHub 24 times in a row (about a day), it says so once, in a separate "WATCH IS BLIND" message, because a watch that has stopped reading looks the same as a green monitor. The repository is public, so this reads with no token: no GitHub secret was added, and the bot token stays in Key Vault. The cost is that it cannot speak while Azure is down; GitHub's own failure email still does. The threshold now lives only inscripts/check-deploy-drift.mjs(8 hours, was 24 there and again in the workflow). Functions need a deploy for the message to start; the threshold takes effect on merge. (functions/src/lib/timers/delivery-monitor-watch.js) -
A monthly audience figure the site can state: a Cloudflare Web Analytics beacon at the edge and a monthly read into the Ambassador hub (#1302). Owner decision 2026-10-20: the Docker Captain application goes in on the Thought Leader track, which asks for 5,000 monthly views, and the Microsoft MVP form asks for impact metrics, so the site has to be able to state a monthly audience figure. Nothing is shown to visitors.
infra/frontend.tfcreates a Cloudflare Web Analytics site forhybridcloudworks.comwith Cloudflare's lite option (no cookies, no client-side state, no fingerprinting; the option asks Cloudflare not to measure visitors in the EU, which Cloudflare documents for the snippet its proxy injects and which is unverified for the hand-placed one this site carries, so the figure is aggregate page views, read as a floor), counted on the newcloudflare_account_idworkspace variable so that a plan without it stays whole, and thecloudflare_api_tokengains Account → Account Settings → Write for the one account-level create.web_analytics_site_tokenandweb_analytics_site_tagare outputs and not sensitive, because the token ships in every page. The frontend build (frontend/scripts/cf-web-analytics.mjs) puts the beacon before</head>only whenVITE_CF_WEB_ANALYTICS_TOKENis set —deploy-azure-frontend.ymlreads it from theCF_WEB_ANALYTICS_TOKENrepository variable — leavingindex.htmlbyte for byte unchanged without it and failing the build on a value that is not token-shaped;connect-srcgainshttps://cloudflareinsights.com. TheambassadorAudienceMonthlytimer runs daily at 06:45 UTC, becausealert-timer-overduecannot express a calendar month, and records the previous month once: page views from Cloudflare's GraphQL Analytics API with the read-onlyCLOUDFLARE_ANALYTICS_TOKEN(Account Analytics: Read, a second token and never the workspace's) and podcast downloads from Podbean through the existing client ("podcast downloads unavailable" in the row until that credential is seeded), as onemanualevidence row per programme under Docker Captain's and Microsoft MVP'sreachrequirement, dated the last day of the month and idempotent by a deterministic id. Two riders: the footer carries a "GitKraken Ambassador" text link, and the Landing Zone Builder's credit reads "based on HashiCorp's validated pattern", the wording HashiCorp's trademark policy allows, with the pattern linked. The privacy policy names Cloudflare Web Analytics as a processor and says we have asked Cloudflare to exclude EU visitors, not that they are excluded. Owner steps, in order: add Account → Account Settings → Write to thehcp-terraform-cloudflare-dnstoken; setcloudflare_account_idonhcw-azureand apply; confirm the EU option on the new site's Manage page in the Web Analytics dashboard (https://dash.cloudflare.com/?to=/:account/web-analytics) and record the date in thecloudflare_account_idrow of required inputs §4.1; setCF_WEB_ANALYTICS_TOKENfrom theweb_analytics_site_tokenoutput and deploy the frontend; mint the read-only token and seedCLOUDFLARE-ANALYTICS-TOKENon Integrations → Keys, then takeCLOUDFLARE_ANALYTICS_TOKENoffEXPECTED_UNRESOLVEDinscripts/check-unresolved-secrets.mjs; addAMBASSADOR_AUDIENCE_MONTHLYtoenabled_timers. Required inputs §4.1, §4.2, §4.5, §4.6, §4.8 and §4.10 carry the rows. -
GitKraken article series: three how-to drafts for the Q4 2026 Ambassador quarter (#1078).
docs/content/blog-gitkraken-01-commit-graph.mdreads this repository's 1,193 commits in GitLens 19.4's Commit Graph without an account;blog-gitkraken-02-mcp-server.mdexplains what the GitKraken MCP server's Git and pull-request tools do inside an AI coding session on a platform that ships every change through a pull request; andblog-gitkraken-03-stacked-prs.mdsets the working agreements' one-conflict- at-a-time rule and theCHANGELOG.mdcollision beside GitLens 19.3's Rebase Stack and Desktop 12.6's stacked pull requests. Every repository figure was read on 2026-10-20 and every product claim is quoted from the release notes of that day, with the unverified items listed in each file's trailing Sources comment. Listed inmkdocs.ymlas drafts like the lab and Docker series. The owner imports them once from Admin → Drafts, reviews, publishes, and logs each on the programme's quarterly check-in by 2026-12-31; the programme asks for two pieces a quarter. -
Portainer read-only check on the lab host, reported into the Health Hub (#1282). The
portainerrole installshcw-portainer-check: every 15 minutes, and once on eachbootstrap.shrun, it reads Portainer on the host's loopback with the owner's access token (GETonly; the token root0600, handed to the unit throughLoadCredential, never on a command line) and hands one content-free report of counts, states, version and licence dates to the site through the lab agent's newbin/report-host-check.jsandPOST /api/agent/reportHostCheck, kept inadmin_config/host_checks. The Health Hub's Portainer card (Enhanced) judges it: degraded on an unhealthy or restarting container, an image with an update or a licence within 30 days of its end; critical when Portainer or itslocalenvironment is down, the check failed, or no report has arrived for 45 minutes. A failed run raises the existing LAB-2 alert; no new rule. The token is the owner's own (owner decision 2026-10-19), set withhcw-vault-setasvault_portainer_api_token; until it is set the check is skipped and the card says so. -
Visitor lab sessions: brokered ephemeral Coder workspaces for visitors outside the org (#1016).
- Implemented
functions/src/lib/labs/visitor-session.jsto broker ephemeral Coder users (v-<hex>) and workspaces from templatehcw-labwithout granting permanent sign-up or accounts. - Enforced strict fail-closed constraints: origin lock against site apex/www, Cloudflare Turnstile token validation (
action: visitor-session), per-client daily quota (2 sessions/day), global concurrency limit (LABS_VISITOR_MAX_CONCURRENT=2), and a 60-minute hard duration limit. - Added self-healing opportunistic reaper cleaning up expired workspaces and users from Coder and Cosmos DB
lab_jobs. - Registered anonymous API endpoint
public/labs/visitor-sessionsupporting GET (status & active session), POST (start session), and DELETE (stop session), registered in.azure/api-surface.jsonandfunctions/function-inventory.json. - Updated frontend
publicApi.jsandLabPanePage.jsxto render visitor session controls with Turnstile verification, frame active visitor workspace URLs with an "End session" control, and display clear capacity notices when full. -
A private "Doc Vault" in the admin, for the owner's own templates, applications and letters, kept apart from the Ambassador Evidence tab (owner request 2026-10-26).
/admin/doc-vault(Admin → Spotlight → Doc Vault, publisher and above) holds documents of six categories (Template, Application, Letter, Notes, Reference, Other) with tags (12 × 40), an optional https or/admin/link and up to 256 KB of plain text or markdown, shown exactly as written in a read-only box and never as a web page. Add by Paste a document or by Upload files (several.md,.markdown,.txtfiles, sent one at a time with a result line each; a failing file does not stop the rest; front mattertitle,category,tagsincluding Obsidian's one-per-line form, andlinkare read, every other key is named in a warning and not kept). Each document has a Copy button (the text as saved) and Copy as plain text (without markdown marks), falling back to the older copy command where the clipboard is blocked. Adding a document with the same category and title as one already here is a new version, never a duplicate (identical text writes nothing); the newest ten versions are kept, each can be read and restored, and a restore is itself a new version. Move to Trash keeps a document and its history for 30 days (put back, or Delete forever from Trash), and the list load removes what is older. Nine routes undercms/doc-vault(functions/src/lib/doc-vault/), all at the publisher role, reads included: viewer is monitoring only, editor is the role an invited co-author holds, publisher is the first role trusted to put things in front of the public, which is the bar for the place that must stay out of it. Its own Cosmos container,doc_vault(/id, the text not indexed, no TTL; backed up as authored work,infra/cosmos-containers.json, the plan checker declares the one create), created by a Terraform apply that stays the owner's: until then every route answers 503NOT_PROVISIONEDand the page says the one step. Document ids are an HMAC of category and folded title under a salt item kept in the container, so an id in an audit row, a URL or telemetry cannot be checked against a guessed title. Every write and every read of a document writes a content-free audit row (id, version, category, source, size, admin id); text, title, tags and link never reach a log line, an audit row or an error, and answers areCache-Control: private, no-store. Writes carry the etag of the version on screen (412CONFLICTotherwise), the old text is archived before it is replaced, and a permanent delete marks the documentpurgingbefore it removes anything so it cannot lose a live document's history or leave an orphan. Registered infunction-inventory.json,.azure/api-surface.json,route-inventory.test.js(publisher on every route, not public), the client timeout table and the export classification. The byte-counting JSON body reader moved fromdrafts-handlers.jsintofunctions/src/lib/http/bounded-body.jsso both read a body the same way (behaviour unchanged, the Drafts tests pass). Never public is held byfunctions/src/lib/doc-vault/no-leak.test.js: no module outside the vault names the container, a vault document fails every public clause in every state, every public route run against a store that answers every read with a vault document serves none of it, and the page is under/adminonly. A security review of the diff (the read-onlytypescript-security-revieweragent) found no Critical or High issue; its six findings were acted on (salted ids, safe delete order, read auditing, no-store, backup wording, spell check off in the text box) except that the publisher role is shared by every publisher, which is stated in the runbook with the one-line change tosuper_admin. Checked: 259 new backend tests (four files underfunctions/src/lib/doc-vault/) and 99 new frontend tests, with the full suites green (functions 7,044 tests, frontend 5,444); mutation checks (19 deliberate breaks of the real code, each caught by the new tests, including a quadratic scan and a public-shape document) and the linear-time test failing on the first draft of the plain-text converter before its scan was bounded.docs/runbooks/doc-vault.mdexplains what goes here and what does not (and the difference from Evidence), who can use it and why, and the one-time setup. Not yet live: the container does not exist until the owner confirms the HCP Terraform run, the owner deploys the functions and the frontend, and the copy buttons were tested in a test browser, not in Edge, Chrome or Firefox on Windows. -
Admin → Calendar: the owner's own dated alerts, on the existing Calendar page, with a pop-up on sign-in and the Docker-Sponsored Open Source application (due 2026-10-30) as the first (#678). Owner request 2026-10-26, "Calendar Alerts when I log into the Admin portal", then "we already have a calendar in the admin portal so maybe we can build off of that": so there is no page or menu entry of its own. An alert is a title, a UTC day, an optional UTC time, an optional link (an https address or an
/admin/...page) and a note of up to 500 characters; it is open, snoozed until a day, or done. On the Calendar (/admin/calendar) an alert is an orange "Alert" chip with a bell on its due day (a new kindalertin theGET cms/calendarread, one more collector inCOLLECTORS,functions/src/lib/calendar-alerts/collector.js; same editor role, read-only, handed no write function), filterable with the toolbar's Kinds and Status; pressing it opens Done or Reopen, Snooze, Open link, Edit and Delete, and none of the reschedule, unschedule, share or cancel actions a content item has. A Calendar alerts panel under the grid lists every alert with Open, Due soon, Snoozed, Done and All filters and adds, edits, finishes, snoozes and deletes them; a change in the panel makes the grid read again and a change from a chip reloads the panel. Download .ics builds a calendar file in the browser and saves it to the owner's disk, so it has no web address. On sign-in an editor or above sees a dialog (focus goes to the dialog, not to a button; focus kept inside it; Esc closes it; roledialog) listing every open alert due within 7 days or overdue, each with Done, Snooze (1 day, 3 days, next week) and Open link; it opens at most once per browser session and once per UTC day, only inside the admin layout, and never blocks sign-in: a failed read shows a small notice with Try again, and a failure to even load the pop-up renders nothing. Backend:GETandPOSTcms/calendar-alertsandPATCHandDELETEcms/calendar-alerts/{alertId}(functions/src/lib/calendar-alerts/), editor and above, registered infunction-inventory.jsonand.azure/api-surface.json. The alerts keep their own store, one documentadmin_config/calendar_alerts(no new container), and not a content or social record, because the Calendar owns no storage and the records it shows are the ones the scheduled publisher (scheduledPublishDate), the Publer sync (scheduledAt) and the newsletter send act on: an alert stored as one could be published or posted. It is written under its ETag with a retry on a lost race, and is read by no public route, the content manifest, the prerender, the sitemap, the newsletter or the social queue. The starting alert is made by the server on the first list read after the deploy, create-only under the fixed iddocker-dsos-application, with the ids of every alert ever made recorded in the same document, so a second read changes nothing and an alert marked done or deleted is never made again (the Calendar read adds it in memory, writing nothing, if it is read first). Titles, notes and links are never logged, and the audit row holds the alert id, its state and, for an edit, the names of the fields changed, never their values. Links are printable ASCII only and may not point at an OAuth route; the link button names the site it goes to; a stored row is checked again on the way out, and the .ics builder drops control characters and the other line-end characters so no title or note can add a calendar property. Runbook: Calendar Alerts. Separate on purpose from the Telegram reminders sheet. Landed in #1390.
Quality & Verification¶
-
Every workflow job that runs this repository's code now has a time limit, and a test keeps it that way (#1441). A job with no
timeout-minutesgets GitHub's default of six hours, so a test stuck in a loop (one vitest's own timeout cannot interrupt) would leave a pull request on "pending" for hours instead of failing. Each job that checks out the repository and then runs a shell step now carries a limit a few times its slowest successful run in October 2026, read from the Actions history (for example theverifyrows 20 minutes against a slowest of 3.4,ansible-lint35 against 10.8, the lab image build 60 against 17). Thee2elimit goes from 25 to 60 minutes, because its slowest normal run took 19, 16 of them installing Chromium. Two jobs are sized for waits they are built to do rather than their run time:monitor-functions-registered(40, as it may wait up to 20 minutes for a deploy to finish) andpublish-lab-image's publish job (60, as it rebuilds as slowly as the build job when the cache is empty).scripts/ci-workflow-steps.test.mjsnow reads every workflow and fails when such a job has no limit, or one that is not a plain number within GitHub's cap; the three jobs its rule leaves out (addon-image-scoutscout,dependency-review,docs-pagesdeploy) are listed in the test with the reason. The two jobs that open a firewall window in Azure for their runner and close it in analways()step (deploy-functionsandpublish-content-manifest's build job) also give every step up to the last close a limit of its own, adding up to at least five minutes less than the job's limit (55 of 60, and 19 of 25). A stuck step there now fails on its own limit, the close steps run as after any failure, and the job's limit can never cancel the job while a window is open; the test checks this too. Each step that adds a rule now writes the name its close step needs before the add rather than after it, and each close step checks the rule is there before removing it and says which case it found, so an add that succeeds in Azure and then hangs until the step's limit stops it still gets cleaned up. The four close steps also no longer give up at the first error. GitHub runs a step's shell withbash -e, which stops at the first failing command, so a single failed read, or a Deny that Azure refused with 409 Conflict because the open step's update was still running when its limit stopped it (Resource Manager runs one update to a resource at a time), used to skip the removal and leave the window open. Each close step now turns-eoff and runs every Azure call throughwindow_retry(scripts/lib/window-retry.sh), retrying with growing pauses (the storage Deny for up to about two and a half minutes); when it cannot read whether the rule is there it removes it anyway; and it ends by reading the result back and failing with an error if the default action is not what it should be or the rule is still there. The storage and SCM close limits go from 3 to 5 and 4 minutes to fit the retries, andInstall dependencies,Azure LoginandAssert every expected function is registeredgive back a minute each (2, 2 and 5 minutes, against slowest runs of 12 s, 17 s and 44 s), so the deploy's step limits still add up to 55 of its 60.scripts/ci-workflow-steps.test.mjsruns each close step against a stand-inazto hold this. No job or check name changed. -
The frontend's Doc Vault "linear time" test no longer fails by chance (#1433). The test (
frontend/src/pages/admin/docVault/docVaultModel.test.js, "is linear: pathological input grows in step with its size") checks that the browser's "copy as plain text" converter takes about four times as long for four times as much text, not sixteen times, which would mean it had become quadratic (slower and slower as the text grows). It timed the converter the same weak way as the server's twin of the test, fixed in #1434 for #1431. - The problem. The large input was timed as five single conversions, all inside a few milliseconds, against a small input with a 0.02 ms floor under it. A slow spell on a busy machine slows all five large timings at once, and nothing cancels it. Run alone here, the old test's ratio reached 8.76 against its limit of 9, and in 30 runs on Node 26 (the version CI uses for this job) it failed once, with
angle: expected 10.9 to be less than 9. The floor also hid the real time of the "ticks" shape (0.005 ms), so its ratio was always read as 1.02. - The fix. The server test's method, copied (the two packages share no test helpers): each batch repeats the conversion until it lasts at least 4 ms, the two sizes are timed in alternating rounds, each keeps its fastest of nine rounds, there is no floor, and a failure prints both timings. Two additions: the repetition count is the first at which the faster of two timed batches lasts 4 ms, so one slow first batch (the converter not yet compiled, or a pause) cannot settle on too few (raised in review); and the small batch reads at least as much text as the large one, so both batches last about as long. Without it, "brackets" timed one conversion of 11 ms against one of 47 ms, and in a whole-suite run with other programs keeping the machine about six times busier than it has processors, it read 9.5. The six input shapes, their sizes and the limit of 9 are unchanged; each shape is now its own test, so a failure names it.
-
How it was checked. 30 readings of each shape: highest ratio 8.76 before and 4.66 after. 30 runs of the file each way, taken in turns on Node 26: 1 failure before, none after, and the group takes 1.7 s instead of 2.2 s. It still catches a slow converter (a temporary local change, not committed): letting the link scan read to the end of the line, the mistake the converter's own comment warns about, fails "brackets" (17.2) and "links" (15.8); a slowdown in the square of the text length fails all six; at 1/1,024 of that cost it fails "ticks", as the old test did.
-
The Markdown draft parser's "linear time" tests use the same steadier timing (Refs #1431). The tests (
functions/src/lib/cms/repo-draft.test.js, "the parser stays linear on hostile text") check that the parser for drafts pasted or uploaded as Markdown takes about four times as long for four times the text (the limit is 8 times), so a hostile file cannot keep the server busy. - The problem. Each size was timed as the best of three single runs, one size after the other, with a 1 ms floor under the small timing. While another pull request ran the whole
functionssuite on a busy machine, "finds links in a body of repeated ](a" failed once with 11.0x. Four of the six cases also took under 1 ms, so the floor hid their real ratio. - The fix. The method of the entry above: that of #1434, plus its two additions: the count of repetitions is confirmed by the faster of two batches, and the small batch reads at least as much text as the large one (here "bare ](" timed one run of about 10 ms against one of 40 ms). The first of the two is also made in #1434's own test,
functions/src/lib/doc-vault/markdown.test.js, which had the same calibration. The inputs, the limit of 8 and the separate 15-second ceiling test are unchanged. -
How it was checked. On Node 24 (the version CI uses for this job), 30 readings of each case: highest ratio 5.50 before and 4.36 after, and the four cases under the floor now read 3.6 to 4.1 instead of 0.15 to 0.93. 30 runs of the file each way passed; the group takes 2.4 s instead of 0.6 s. It still catches a slow parser (temporary local changes, not committed): putting back the old link pattern fails all three link cases (17.4, 16.0 and 63.2 times) as the old test did; a slowdown in the square of the input length fails all six, where the old test missed the cubic case; at 1/16,384 of that cost it fails two, where the old test failed none.
-
A test now fails when a comment beside a lab host pin names a version or digest that the weekly bump will move, and the 23 comments that did are reworded (#1444, found on the bot's pull request #1423).
- The problem. Every week a bot (
.github/workflows/lab-supply-chain.yml, runningscripts/lab-pins-upstream.mjs --bump host) opens a pull request that moves the lab host's pins (the exact versions and checksums it installs; a checksum is a fingerprint of a file, and a digest is the same for a container image). It moves the values and, on purpose, leaves the sentences in the comments beside them alone, because a script cannot rewrite prose safely. So a sentence such as "2.11.4 is the newest release" was true when written and false after the first bump. CodeRabbit (the review bot) asked for changes on #1423 for three of them (Caddy, Coder, Node.js), and there were more: the test reported 23 onmain, 21 inlab-host/ansible/group_vars/all.ymland 2 inlab-host/bootstrap.sh. Fifteen already contradicted their pin (Docker Engine, Caddy, Coder, PostgreSQL, Portainer, Vault, Node.js, cosign), three named PostgreSQL's current tag and would have been false after its next bump, and five came from one comment that sat above the wrong pin (the add-on comment, which says "no automation moves these", sat overcosign_version, which the bot does move). - The fix. A comment beside a pin that the bump moves now names no exact version and no digest. It says "the pin below", or names a release line (the first numbers of a version:
2.38, or18), and it keeps its history as dates ("read 2026-09-26", "released 2026-10-06") and as what was checked. The bot is not changed: it still edits values only, which is safe once no comment repeats them.scripts/lib/lab-pin-comments.mjsis the rule in code. "Beside" means the comment lines directly above the pin's group of lines, a comment at the end of the pin's own line, and every comment in the caddy test (a short script whose header describes its pin from some lines away). An exact version is one with as many dotted parts as the pin (29.9.2, or18.6for PostgreSQL), so release lines,26.04,HTTP/1.1and dates are left alone, and a26.04beside a two-part pin such as18.6is flagged. The test also fails on a comment that names the pin's current value, because that comment is true today and false after the next bump, and this way it fails when it is written and not in the bot's next pull request. The failure message names the file, the line, what the comment says and what the pin holds, says how to fix it and points atlab-host/README.md, "Bumping a pin". - What was reworded. The Docker, Caddy, Coder, PostgreSQL, Portainer, Vault, Node.js and cosign comments in
group_vars/all.yml, and the cosign comment inbootstrap.sh. The dates, what each digest was read from, the Coder2.38breaking changes, the PostgreSQL 16 to 18 rehearsal and the Portainer and Vault support lines are kept. Two things moved so each comment sits over what it describes: PostgreSQL's part of the Coder comment now sits above PostgreSQL's own lines, and the cosign pair moved above the add-on comment. A note at the top ofgroup_vars/all.ymland a paragraph inlab-host/README.md, "Bumping a pin", state the rule. The same class of sentence in the documentation now points at the pin instead of repeating it: the component table at the top oflab-host/README.md(which named Docker Engine 29.8.1, Caddy 2.11.4, Coder v2.37.3 and Vault 2.1.1 against pins that had moved on), the Docker CLI note inlab-host/README.mdandroles/labs_agent/README.md, the example output lines for Portainer,roles/coder/README.md,roles/vault/README.md,roles/portainer/README.mdanddocs/runbooks/labs-host.md. Dated measurements ("measured on v2.38.0 on 2026-10-17") and examples inargument_specs.ymlare kept as they are. The docstring ofroles/coder/files/hcw-coder-automation.py(the text at the top of a Python file that says what the file does) is reworded (documentation text only: its code is unchanged, which a comparison of the parsed syntax confirms), so the nextbootstrap.shrun lists that one file as changed and does nothing else for it. - What does not change. No pin value: the parsed
group_vars/all.yml(read with a YAML parser, 87 top-level keys) is identical before and after, in the same order, andbootstrap.shhas the same lines apart from comments. No workflow or ruleset (the repository's merge rules) is edited, so the names of the required checks (the checks a pull request must pass before it merges) are the same, and nothing was run against the lab host. -
How it was checked. 20 new tests, 35 in
scripts/lab-pins-upstream.test.mjsinstead of 15. Sixteen check the rule on small texts (a different version, the pin's own value, av-prefixed tag, a digest prefix of eight hex digits or more (hex digits are 0 to 9 and a to f, the characters a digest is written in), what is allowed, which comment belongs to which pin, every line of a long comment, a nested key, a shell file, the$commentof a JSON file (JSON has no comments, so the Docker CLI fixture keeps its notes in a field of that name), the caddy test, two files whose comments sit on the same lines, a pin listed twice and Windows line ends, the message and the page it points at, a pin that cannot be found). Four run the real bumpers (the functions that make each edit, throughplanHost, the one that plans the whole weekly edit, with every watched product behind and fake publishers, that is publisher answers made up for the test so that nothing goes over the network) over the real files: the list of all 29 lines the bump edits is written out in the test and must match exactly, no comment names a version today, none contradicts a pin after every value has moved, and a comment that still names the old value after a bump is caught with its file and line. With the new test and the unchanged files frommain, the two real-file tests failed with the 23 reports. Twenty-three deliberate breakages (three comments put back, twenty changes to the rule, its message or a bumper, such as a bumper that stops editingvault_checksum) were each caught by at least one test, then undone. Run for real against the publishers in a scratch copy of the changed files,--bump hostmoved nine value lines, the same nine as #1423, and no comment line, and the rule reported nothing on the result. An independent read-only review of the first version found parts of the rule that no test pinned (thevprefix, the lines of a long comment run, JSON, the eight-hex-digit limit for a digest, the file name in the key that groups pins) and the list of lines the bumpers edit; each has a test now. -
The CI path-filter guard now follows what a module in another package imports and reads, so a change to a file two or three steps away from a test can no longer skip that test's job (#1428). Part 1 of that ticket: the check that each test job's path filter lists every file the job's tests need. Parts 2 and 3 are in #1430.
- The problem. Each test job in
.github/workflows/ci.ymlruns on a pull request only when the pull request changes a file that the job'sfilter(a pattern for file paths) matches.scripts/ci-filters.test.mjsis the guard: it fails when a filter misses a file the job's tests need from outside their own package (a folder with its ownpackage.jsonand tests, such asfrontend/orfunctions/). Until now it counted only the one outside file a test imports, not the files that file imports in turn, or reads. For examplefrontend/src/lib/aiEngine.test.jsimportsfunctions/src/lib/ai/router.js, which loadsai/select.js,cosmos-client.js,secret-vault.jsand about twenty more files. A pull request that changed only one of those did not run thefrontendjob, although the test loads it. - The fix. The guard now opens every module a test reaches, in any package, and follows the chain to its end. It reads each module's imports from its code:
import ... fromandexport ... from(a name passed on from another file),import('x')(loads a file while the program runs) andrequire('x')(the older way of loading a file) when the path is written out, andvi.mock('x')(a test's way of replacing a module, which still names it). A file it cannot parse, such as TypeScript, is read as plain text. For each module it also works out the files the module reads when a test runs it, with the analysis it already used on the test files, and it follows animport()orrequire()whose path is worked out as the program runs when that names one file. It leaves out what a pull request cannot change: package names such aslodash, anything undernode_modules(where installed libraries go) or.git(git's own data), and thedistandcoveragefolders that sit beside apackage.json(a package's build output and coverage report). Acoverageordistfolder lower down, such asdocs/coverage/, counts like any other. The guard is also faster: it keeps the answers the disk gives and no longer parses a file twice. A parsed file serves one analysis only, because the analysis writes notes onto it; a second round parses it again (a review found that reusing it could make a read drop out unseen). - What it found. 54 files for the
frontendrow (one entry in the list of test jobs inci.yml) and 36 forscripts (operations), and none forfunctions (azure),vps-agentor the edge probe. A reviewer counted 53 and 36 by hand for #1427. The 54th,functions/src/lib/ambassador/model.js, is imported byambassador/readiness.js, which #1438 added to the filter one step short of it. All of them are plain imports, and none of those modules reads a file from disk, so the fix is in the filters.frontendnow lists theai/,cms/andlabs/folders offunctions/src/lib/whole, because the tests load eight or more of their code files (20 of the 25 non-test code files directly inai/, 10 of the 23 incms/, 8 of the 19 inlabs/), and 23 single files.scripts (operations)listscms/,labs/,http/andcloud-tools/whole and 12 single files. Each group has a comment naming the test behind it. - What it costs. A whole folder also matches the test files in it, which nothing loads (66 of them in the three
frontendfolders), so a change to one of them runs the job needlessly. Replayed over the last 150 merged commits withgrep -E(a text search) as the workflow runs it,frontendwould have run for 75 instead of 69 (two of the six are changes to files the tests really load, four are changes to test files infunctions/src/lib/cms/),scripts (operations)stays at 141 and the other rows do not change. Over the last 400 commits it is 227 instead of 211 forfrontendand 375 instead of 374 forscripts (operations). - What to do when the guard fails. The message names the file and the chain of modules that leads to it. Either add the file to the row's filter in
ci.yml(a whole folder when many of its files are listed), or stop the test reaching it. Two new reasons to fail: an import in a module outside the package whose file cannot be found, and a read the guard cannot work out in such a module. For these a person writes an entry inREVIEWED_READS, the list in the test file where the reason a read is fine is written down; for a missing import the entry names it asimport '<path>'. An entry may now name a module outside the package, and is stale only when no package's tests use it. -
How it was checked. 77 new tests, 255 in the file instead of 183. 73 of them build small temporary repositories for each way a chain can go (a module that imports another, a re-export,
import(),require(), a read withjoinorresolve, an import cycle that must end (two files that import each other), an import that cannot be found, a package name,node_modules,coveragebelow a package's top, a second round of the analysis); 71 of those 73 fail onmain's guard code and all pass on this one, and the other two only guard what must not change. 50 deliberate breakages of the new code (a folder rule dropped, a kind of import no longer counted, a used syntax tree used again, and so on) were run one at a time against the tests: 49 each make at least one test fail, the 50th (removing the guards against import cycles) makes the run never end, and each breakage that first got through now has a test. The test file takes about 12 seconds on the machine it was timed on against about 20 formain's, and its peak memory is about 1.2 GB against 0.7 GB. The "what it does not see" note at the top of the test no longer lists this limit. -
The CodeQL (code security scan) and Terraform/Trivy (infrastructure) checks now fail loudly when their path filter cannot be read, instead of being switched off (#1429). The same weakness #1428 fixed in
ci.yml. - The problem. A job decides whether a pull request needs it by running
grep -E(a text search) with a filter (a pattern for file paths) over the files the pull request changes.grepanswers 0 (a file matches), 1 (none does) or 2 (it could not read the pattern, for example because a parenthesis is never closed). The change-detection steps of.github/workflows/codeql.yml(Detect language changes, once for each of the three languages) and.github/workflows/iac-validate.yml(Detect infra changes, in theterraformjob and in thetrivyjob, each decidinginfra/andinfra-lab/) tested that withif grep ..., which treats 2 like 1. A broken filter would therefore have skipped a CodeQL analysis, or the Terraform or Trivy checks, as "no relevant change" while the required check stayed green. - The fix. The steps now use the same few lines as
ci.yml: 0 writes the "relevant" answer, 1 writes "not relevant", and anything else fails the step with a message that names the job and the filter. A diff that cannot be computed still runs everything, and a push tomainstill runs everything. The two patterns thatiac-validate.ymlkept inside the script now sit inINFRA_FILTERandLAB_FILTERsettings, with this workflow's own file added by the script as before. - What does not change. The job ids, the job and matrix names that the
mainruleset (the repository's merge rules) requires, the triggers, the permissions and theif:conditions are untouched, and a step that fails fails the same check, which is the point. The paths that count as a change are the same: the old and the new pattern picked identical paths for all five patterns, over 22,686 paths (the 3,084 the repository tracks and the rest made up:infra,infra-labandinfra-labsfolders,.js,.jsx,.pyand.pycendings, near misses of the workflow names). - The tests. The new
scripts/codeql-iac-workflow-steps.test.mjsruns each step with bash in a temporary git repository (a matching file, a file that does not match, three filtersgrepcannot read, the workflow's own file, a base commit that cannot be fetched, a push tomain), checks that the lines that decide are the same in all seven change-detection steps of the three workflows, and scans every file in.github/workflows/forif grep. Nine lines in five other workflows remain, each listed in the test with its reason: four are negated (if ! grep, which means "stop unless it matches"), so agrepthat cannot read answers "stop"; five refuse a bad patch with a fixed pattern over a file that another command in the same step also reads, and that command fails the step if the file cannot be read. A newif grepfails the test. Against the oldcodeql.ymlandiac-validate.yml, 14 of the new tests fail: the old CodeQL step printed "no python change, skipping analysis" and exited 0 on an unreadable filter. - Housekeeping, and one detail. The code that cuts jobs, steps and matrix rows out of a workflow, the git settings the tests use and the
if grepcheck now live inscripts/lib/workflow-text.mjs, shared withci-workflow-steps.test.mjs. Iniac-validate.ymltheinfra/answer is written before theinfra-lab/filter is read, so if only the second filter is broken the first answer is already written; the step still fails, so no later step runs and nothing is skipped. -
Decision record: 0026.
-
A CI job whose filter
grepcannot read now fails loudly instead of being switched off, and the filter guard now runs on every pull request (#1428). Two follow-ups to #1427. First, each job in.github/workflows/ci.ymldecides whether a pull request needs it by runninggrep -Ewith the job'sfilter(a pattern for file paths) over the files the pull request changes.grepanswers 0 (a file matches), 1 (none does) or 2 (it could not read the pattern, for example because a parenthesis is never closed). The four change-detection steps (theverifymatrix,ansible-lint (lab-host),coder (lab-host)andharness (python)) tested that answer withif grep ..., which treats 2 like 1, so a broken filter would have skipped its job as "no relevant change" and left the pull request looking green. The steps now keep the answer: 0 writesrelevant=true, 1 writesrelevant=false, and anything else fails the step with a message that names the job and the filter. A diff that cannot be computed still runs everything, as before. The three steps that kept their pattern inside the script now take it from aFILTERsetting like the matrix step, so the lines that decide are the same in all four, and the filter is put in brackets beforeci.ymlis added to it, so a stray backslash at the end of a filter cannot join onto the|and hide the mistake. The newscripts/ci-workflow-steps.test.mjstakes each step's shell out ofci.ymland runs it with bash in a temporary git repository (a matching file, a file that does not match, three filtersgrepcannot read,ci.ymlitself, a base commit that cannot be fetched, a push tomain); on the oldci.yml20 of its tests fail, the broken filter ending with exit code 0 andrelevant=false. Second, the guard (scripts/ci-filters.test.mjs) lives in the scripts package, so it ran only when thescripts (operations)row ran, and a pull request that changed only a frontend or functions test first met it onmainafter the merge. A new step, "CI path filters list every file the tests read", runs it on every pull request, outside the filter, like the two checks beside it (oidc-subjects.test.mjsis the model), and the scripts row now installs its packages on every pull request so the step can run; the same new test file keeps the step outside the filter and works out which steps each row runs for a pull request that changes onlyfrontend/src/x.test.jsx. Replayed over the last 150 merged commits onmain, the guard ran for 141 (94%) before and runs for all 150 now; adding the other packages' test files to the row's filter would have reached 143 and adding their folders whole 145, and both keep a list that someone has to keep up by hand. On the runner, in this change's own CI run, the install took 5 seconds and the new step 12 (about 15 seconds for the guard on its own on the machine it was first timed on), and the scripts row is not on the longest path: on the last run onmainscripts (operations)took 53 seconds andfrontend165.codeql.ymlandiac-validate.ymlstill used theif grepshape when this was written and were not changed here (fixed in the entry above, under #1429). Decision record: 0026. -
The Doc Vault "linear time" test no longer fails by chance (#1431). The test (
functions/src/lib/doc-vault/markdown.test.js, group "linear time") checks that the reader for uploaded Markdown files takes about four times as long for four times as much text, not sixteen times, which would mean the reader had become quadratic (slower and slower as the text grows, so one big file could keep the server busy). It does that by timing the reader with a clock. On 2026-10-27 it failed in CI run 40484952326 with a ratio of 11.9 (the limit is 9) on a pull request that changed only documentation, so the cause was timing noise on the shared test machine and not the reader. The failure could not be repeated here (0 failures in 300 runs of the file on an idle machine and in 100 runs with 48 busy programs running beside it), so the cause was read from the timings instead. The large input was timed as five single parses of about a tenth to a quarter of a millisecond each, all inside about two milliseconds. Across 8,144 local readings of the old measurement the ratio of the four shapes that do real work was normally 3.5 to 4.1 and reached 6.9 at most; in the worst cases whose individual timings were recorded (ratios of 5.8 and 6.3), all five large timings were slow together (1.6 to 1.9 times their usual) and the small timings, taken earlier, could not cancel that. Three of the eight shapes also sat under a floor of 0.02 ms that was 3 to 14 times their real time (0.0014 to 0.007 ms), so their growth was never measured. Now every timing repeats the parse until it lasts at least 4 ms, the small and large inputs are timed in alternating rounds so a slow moment lands on both and cancels out, each keeps its fastest of nine rounds, and the floor is gone. The check is still the ratio, still under 9, on the same eight shapes, and a failure now prints both timings. Measured side by side in one process on an idle machine (2,000 readings each): the highest ratio fell from 5.04 to 4.25, and the gap between the median and the 99th percentile for the four shapes that do real work fell from 0.58 to 0.29. It still has teeth: against a reader made quadratic on purpose (a temporary local change, not committed) the old and the new test both fail on all eight shapes when the slowdown follows the text length, and on exactly the shapes a slowdown touches when it follows the line count (3), a value's length (4) or a heading's length (1); for a mild slowdown (1/1,024, 1/16,384 and 1/262,144 of that cost) the new test fails on 6, 3 and 1 shapes against 4, 2 and 0 for the old one. The price is time: the group takes about 1.0 s instead of 0.06 s on an idle machine, and 2.9 s instead of 0.26 s with the busy programs. Not verified: Node 24 (CI's version; this machine has 26.5) and a CI run. The same timing method infrontend/src/pages/admin/docVault/docVaultModel.test.js(the plain-text converter) has the same weakness and is not changed here. -
The CI path-filter guard now works out which files a test reads, so a read written with
join(...)can no longer slip past it (#1414). Each test job in.github/workflows/ci.ymlruns on a pull request only when the pull request changes a file itsfiltermatches, andscripts/ci-filters.test.mjsfails when a test reads a file outside its package that the filter misses. The first version cut each read off at its first comma, soreadFileSync(join(HERE, '..', 'functions', 'x.js'))was read asjoin(HEREand dropped, which is how the Cloud Crawler registry file was missed in #1413.scripts/lib/js-file-reads.mjsnow parses the test withespree(the parser ESLint already uses; it becomes a direct dev dependency ofscripts/) and followsjoin,resolve,new URL, variables, loops and helper functions, including helpers in other files. A read it cannot follow, or a use of the file functions it cannot see through, fails the test instead of being skipped; the only way past is a reason written inREVIEWED_READS, and an entry that no longer matches a read fails too. Run againstmain's filters it reports 40 gaps: 33 files and 4 folders that tests read, and 3.nvmrcfiles that the version-floors checks read. The filters now cover them.functions (azure)also runs for any change underfrontend/src/,frontend/scripts/,frontend/public/andscripts/, because the Doc Vault test reads every source file in them: over the last 150 merged pull requests it would have run on 111 instead of 66,frontendon 67 instead of 64, andscripts (operations)on the same 141. What the guard still cannot see is listed at the top of the test. -
CI now runs
npm run validate:routeson every pull request that touches the frontend (#1345). The validator fails when a page spells a provider path by hand (href="/azure/...") instead of building it from the route factory, which stays correct until a route is renamed and then breaks that page silently. It was not run by CI, so #1337 merged two such paths and only a hand run found them (fixed in #1338). It is a new Route validation step in the frontend row of.github/workflows/ci.yml, after Format check, switched on by avalidatekey on that row so no other package runs it. Seen both ways locally: exit 0 on the current tree, and exit 1 with the file and the path named when a component with a hard-codedhref="/azure/..."was planted, then removed. Not yet seen in a CI run; this pull request's own frontend job is the first. - The AVM versions bot's check step reads the pins instead of remembering
them (#1287).
update-avm-versions.ymlfailed on 2026-10-06 and 2026-10-13 in its ownviteststep after a bump: seven expectations inlabImage.test.js,hcl.test.jsandLzLabValidate.test.jsxcarried the pinned versions (0.21.0,0.9.0,0.22.2, the lab's0.15.0, 0.22.2, an absent0.23.0) as literals that-ucannot refresh. They now derive the pin fromAVM_MODULES, what the lab carries fromLAB_IMAGE_AVM, and an absent release as one patch above the highest carried; the constraint evaluator's table keeps a fixed shelf of its own, since its rule is not about the pins. The same fix moved the path filter in front of-u:vitest run -u <path>took the path as the flag's value and ran the whole suite, refreshing every snapshot in the tree; and the bot's plain check now runs the lab pane's suite as well. Proven by applying the bot's 2026-10-13 bump (avm-ptn-alz0.22.0, the connectivity module 0.17.7, the spoke module 0.22.3) locally: the seven failures reproduced before the change and the three suites passed after it. - The three LAB-2 lab-host rules are on the Fire record (#981). The owner
ran the three tests from
mainon 2026-10-18:alert-lab-unit-failedfired at 14:46Z (ten minutes after the one syslog line),alert-lab-heartbeatat 15:36Z (the agent stopped 14:52Z, started again and active),alert-lab-diskat 16:20Z (a 138.9 GiB file to 87%, removed, 15% again). Every rule the lab host carries has now been seen to fire; the runbook's Fire record rows replace the threenot yetrows. - Fire tests for the three LAB-2 lab-host rules that had never fired (#981, LAB-2).
scripts/alerts/Invoke-AlertFireTest.ps1gainslab-unit-failed,lab-heartbeatandlab-disk, in the script's own shape: a stop condition read overssh hcw-lab, one staged change, a bounded wait on Azure Monitor's fired alerts, and a put-back in afinallythat runs on failure or Ctrl+C and names the exact ssh line when it cannot confirm. lab-unit-failedwrites one syslog line withlogger -t hcw-unit-failed -p daemon.err, exactly as thehcw-unit-failed@notifier does, naming ahcw-fire-test-<UTC stamp>.serviceunit that does not exist; it refuses while the hour's window already holds a notifier line, and has nothing to put back (stateless rule, six-hour mute). Up to 35 minutes.lab-heartbeatstopsazuremonitoragent(neverhimdsd) untilalert-lab-heartbeatsees an empty 30-minute window, and starts it again in thefinally; it refuses when ssh cannot reach the host, alabjob-container is running, or the agent is not active beforehand. Up to 55 minutes, during which the host ships noHeartbeat,PerforSyslogrows.lab-diskreadsdf -B1on the host, refuses past 80% used or when 87% would leave under 10 GiB free, allocates/var/tmp/hcw-fire-test-disk.imgsized on the host fromdfat that moment (rounded down to a MiB) so the root reads 87%, holds it foralert-lab-disk's hourly evaluation, and removes it in thefinally; the lab DCR's 60-second% Used Spacesample sets the floor. Up to 80 minutes.- The runbook's fire-test and recovery tables carry the three, with by-hand blocks and
not yetFire record rows that name the test;Invoke-AlertFireTest.Tests.ps1renders each under-WhatIf, refuses on each stop condition, and holds the runbook's table to the script's-Testlist. - Credential-roll code scanning alerts closed (#1247, #1248 follow-up). The AWS roll's Key Vault rollback no longer re-checks
currentAccessKeyId, which the function already requires (CodeQLjs/trivial-conditional, alert 1416); the fetch stubs infunctions/src/lib/credentials/google-roll.test.jsandscripts/roll-cloud-credentials.test.mjsmatch a probe bynew URL(url).hostnamerather than by a substring anywhere in the URL (CodeQLjs/incomplete-url-substring-sanitization, alerts 1409–1415), so a stub can no longer answer for a host that merely embeds the vendor's name. - Deployment verification: fixed post-deploy frontend probe and deployed smoke test RPC check.
- In
scripts/smoke-deployed.mjs, movedPOST /api/aiProxyinto the live admin guard verification (asserting 401/403 for unauthenticated callers) and verified that unmapped RPCs return 404 without cross-root file imports. - In
.github/workflows/deploy-azure-frontend.yml, addedDEFAULT_HOSTNAME_OVERRIDEdefaulting tocalm-ground-0d0e6a010.7.azurestaticapps.net(with fallback tovars.STATIC_WEB_APP_DEFAULT_HOSTNAME), enabling the post-deploy probe to verify HTTP 200 without requiringMicrosoft.Web/staticSites/readpermissions on the deployer identity. -
In
.github/workflows/deploy-azure-frontend.yml, updatedprobe_urlto stream curl bodies directly to a temporary file, eliminatingSIGPIPE(Broken pipe) underpipefailwhen inspecting large HTML responses. -
Validation & regression test plan: completed contract, accessibility, and infrastructure test gates (#982).
- Contract: added
scripts/ruleset-contexts.test.mjsasserting strict 1:1 parity between GitHub Ruleset 20680114 required status checks and workflow job contexts declared on pull requests. - Accessibility: extended
frontend/src/pages/admin/adminA11y.test.jsxwith fullvitest-axevalidation forHealthPageensuring zero violations. - Infrastructure: added
infra/tests/guards.tftest.hclwith mocked providers verifying absence of keylessAzureWebJobsStorage, environment-only deploy identity subjects, Function App scoping of Cosmos DB data roles, and network isolation, wired directly into.github/workflows/iac-validate.yml. - CI: the lab-host role tests are their own job, a workflow-only change runs the lint-class jobs only, and CodeQL runs after the merge (#1545, second pull request). The owner's question of 2026-11-04 was whether
ansible-lint (lab-host)had to run every time: it took nine minutes on #1549, a pull request that moved no lab-host file, because the job also ran every role test in sequence and becauseci.ymlwas in every job's filter. Three changes in.github/workflows/ci.yml.ansible-lint (lab-host)keeps the lint, the playbook syntax check and the argument-spec check (about a minute) onlab-host/changes; a newlab-host role testsjob runs the twenty-six role tests, only whenlab-host/ansible/roles/,group_vars/,inventory/or one of the files outside them that the tests assert on change (lab-host/coder/,lab-host/bootstrap.sh,lab-host/README.md, two infra files, the labs-host runbook and three vps-agent files, each listed with its test inROLE_TEST_READSinscripts/ci-workflow-steps.test.mjs, which holds the filter to that list), and on every push tomain;gateneeds both. Eachchangesstep now says withWORKFLOW_SELF_TRIGGERwhether a change toci.ymlalone counts: yes for the lint, the harness and the matrix rows, no for the browser suites, the role tests and the Coder template checks, which read no line of the workflow;scripts/ci-workflow-steps.test.mjspins the list and runs each step with bash against a change toci.ymlalone and beside a matching file. The ufw closed-ports test stays as it is: its 195 s are thirty-fiveansible-playbookruns that each change the one scratch ufw the next one reads, so they cannot run side by side without a namespace per run; its header says so. Withgatethe only CI context the ruleset requires (applied by the owner on 2026-11-04),codeql.ymlruns on pushes tomainand weekly, not on pull requests, with its detect step andscripts/codeql-iac-workflow-steps.test.mjs(nowiac-workflow-steps.test.mjs) gone; the trade-off (an alert lands after the merge, no code scanning comments on the pull request) is indocs/runbooks/required-checks.md, and.claude/CLAUDE.mdsays the five required checks are in force and that a pull request title is one line of at most about 72 characters in the shape<area>: <what changed> (#<issue>).
Security¶
-
Lab image: a newer Azure CLI can no longer be quietly downgraded, and the review findings on #1466 are tidied (#1426). The
fullbuild now stops, naming the line to delete, when the Azure CLI already ships a package at or above the versionAZURE_CLI_PYTHON_OVERRIDESlists (the weekly bump moves the CLI alone, so a newer CLI would otherwise have been put back on the older package). The replacement now happens in the same step that installs the CLI, so no image layer keeps the old, flagged files, and the ansible-core install runs with no network. An empty or deleted overrides block now builds.smoke.shsigns RS256 and PS256 sign-in assertions with msal, reads a token's tenant id with PyJWT and fetches a page over requests and urllib3 on 127.0.0.1, all with no network, and a newscripts/lab-image-azure-cli-overrides.test.mjschecks every override line's shape.versions.envnow says truthfully why cryptography stays at 48.0.1 (Microsoft still pins pyOpenSSL 26.2.0; newer pyOpenSSL lifts the cap but removes APIs the CLI's certificate commands use, and neither open cryptography advisory is reachable from the CLI) and that the base image's patch-tag digest will move once more when its riscv64 build lands.lab-image/README.mdexplains the overrides block and that urllib3 2.8.0's proxy change leaves the CLI'shttps://proxies checked againstREQUESTS_CA_BUNDLEas before. -
The lab image's critical Python findings are fixed, and the base moves to CPython 3.14.10 (#1426). Every CRITICAL and HIGH Python finding the weekly scan reported in the
fullimage was inside the Azure CLI's own bundled Python under/opt/az, and Azure CLI 2.91.0 is still Microsoft's newest release. The build now replaces certifi (2026.10.23), PyJWT (2.15.1), urllib3 (2.8.0) and the CLI's pip (26.2.2) there, from hash-checked wheels, and runspip checkon the result;smoke.shchecks each version and that the CLI's sign-in libraries still import.lab-image/versions.env(AZURE_CLI_PYTHON_OVERRIDES) records what waits on a publisher: cryptography 48.0.1, which pyOpenSSL in the same bundle caps below 49, oauthlib 3.2.2, whose fix is a new major version Microsoft has not adopted, and the copies of msgpack, setuptools and urllib3 that pip keeps inside itself. The base ispython:3.14.10-slim-trixieby its own index digest (the line tag differed only by a riscv64 image still on 3.14.9), and terraform 1.17.1, kubectl 1.37.2 and azurerm 5.12.0 move to their newest releases, each sum read from the publisher. -
Sign-in library
@azure/msal-nodeno longer forced;@azure/identity4.14.1 asks for it itself (#1346). The 2026-10-24 entry below added an npmoverridesline (a line that makes npm install a version the parent package did not ask for) to force msal-node 7.1.1, because@azure/identity4.14.0 still asked for^6.0.0and every 6.x is open to GHSA-xcx6-jx8p-x3h5.@azure/identity4.14.1, released by Microsoft, now asks for^7.1.0, sofunctions,scriptsandvps-agentmove to^4.14.1and the override line is gone from eachpackage.json. The installed version does not change (npm ls @azure/msal-nodestill shows 7.1.1 in all three, now under a range the identity package declares), and the accepted-risk row inTODO.mdis removed. Not tested: real Azure sign-in, meaning Azure Arc on the lab host and the workload-identity login in CI; confirm after the next deploy. - Sign-in library
@azure/msal-nodeforced to 7.1.1 infunctions,scriptsandvps-agent, closing three High Dependabot alerts (#163, #164, #165). Owner decision 2026-10-24. The alerts are for GHSA-xcx6-jx8p-x3h5 (CVSS 7.5): in msal-node's authorization-code flow, parameters can be sent to an attacker-controlled endpoint; fixed in 7.1.0.@azure/identity4.14.0, which pulls the library in, still asks for^6.0.0, so Dependabot could not fix it by itself; an npmoverridesentry in eachpackage.jsonnow makes every copy 7.1.1. Before opening this, the managed-identity path (the one the Function App uses for Cosmos, Blob and Key Vault) was run against a stand-in for the App Service identity endpoint on 6.0.1 and again on 7.1.1, in all three packages: the same six checks passed both times (system-assigned and client-id tokens, the token cache, concurrent calls,DefaultAzureCredential, and a refused request). All three test suites pass. Not tested: the real Azure endpoint, Azure Arc on the lab host, and the workload-identity login in CI; the owner deploys the functions and watches the delivery monitor. The override is recorded as an accepted risk inTODO.mdwith its removal trigger. - Coder rotation credential: measured, and kept unscoped with the reason (#1037).
On the live Coder 2.38.0 on 2026-10-19, with throwaway tokens of
hcw-status: a token withcoder:templates.author,organization:readanduser:readpublishes the template and cannot mint keys; a token withapi_key:createanduser:readmints tokens with scopes it does not hold and those tokens use them, so the inheritance rule in Coder's current docs is not enforced on this version; scopes are enforced on reads by filtering (workspace:readalone sees no templates), and an out-of-scope write answers 500 rather than 403. Since any credential that renews the status token can mint any scope, narrowing the rotation credential contains nothing against a leak; it stayscoder:alland containment stays on the account. The results are the second table under "The status token for the site" inlab-host/README.md; the role README and the runbook point at it. Three tokens ofhcw-statusthat nothing held (two hand-made status tokens superseded on 2026-10-10, and the old host's rotation credential restored by the snapshot) were deleted. - LAB-9 rebuild rehearsal done on the live lab host: 66 min 42 s (#955).
2026-10-18, VM 939861 recreated through Hostinger's API on the same plain
Ubuntu 26.04 template; Vault unsealed on the snapshot's data 66 min 42 s
after the click, Coder back on the restored database at 58 min. The
runbook's "Reinstalling the host" step 7 is rewritten in the order the
rehearsal proved: the nine vault keys set by hand (three add-on Turnstile
secrets, not one), Arc's two runs and then
Connect-Lab.ps1 -Arcbefore any further play (the post-onboarding run closes port 22), thehcw-azurerun for the new machine identity,bootstrap.sh --skip-tags coder(Coder's first account cannot be made by password on this build, and the restore brings the accounts back), the Coder restore, the seal check, the agent go-live as the publishing run, Vault init and restore in one on-host command whose root token never reaches a screen, the generate-root seed, the Coder automation's rotation credential, Portainer 2.45.2's self-createdlocalenvironment. The Caddy token's client-IP filter now names both of the host's addresses, because the rebuilt host reaches the Cloudflare API over IPv6 and issuance failed until the/128was added; the token is rolled, not re-created, on a rebuild. Required inputs record a Cloudflare token naming rule (where it lives, what uses it, what it reaches) after the audit poller's token was revoked in place of the production-zone Caddy token on the cut-over evening; both corrected the same night, the poller reading again with 0 unexpected. - Lab DNS-01 by delegation into a dedicated lab zone (LAB-1, #948). Owner
decision 2026-10-18: the lab zone is
hybridcloudinsights.com, a domain the owner already held, connected to the same Cloudflare account that day (Free plan, nameservers moved at Hostinger, zone emptied).infra-lab/dns.tfadds_acme-challenge.laband_acme-challenge.coder.labas DNS-only CNAMEs to_acme-challenge.lab.hybridcloudinsights.com(variablelab_acme_challenge_target, outputlab_acme_delegations, test asserts);roles/caddygainscaddy_dns_challenge_override_domain, rendered as the Caddyfile'sdns_challenge_override_domain, because certmagic finds the zone for a challenge name by SOA walk and does not follow a CNAME. The runtime token is re-issued with DNS edit on the lab zone alone and rotated withhcw-vault-set+bootstrap.sh; the lab-host README has the token form, the forced-issuance proof (the stored certificate copied aside first, with the restore line) and the revocation of the production-zone token. Completed the same evening (21:04Z): thehcw-labworkspace applied the two CNAMEs once its VCS source was repointed to the moved repository (it had planned nothing since 2026-10-07), the token was rotated, a forced issuance obtained a new*.lab.hybridcloudworks.comcertificate through the delegation, and the production-zone token was revoked; ADR 0032's LAB-1 interim is closed with the date. The Cloudflare DNS audit'scaddy-acmeallowlist entry is dated to match (CADDY_PRODUCTION_ZONE_ACCESS_ENDED, 2026-10-18T20:53Z, the minute the host switched tokens): a challenge TXT in the production zone from the lab host's address at or after that instant has no credential that could have made it and reads UNEXPECTED, with a regression test (CodeRabbit on #1280). Every other document that still said in the present tense that the token writes the production zone now says until when it did. - Cloudflare DNS audit: an apply that errored or was cancelled is still an
apply (#948). The first live run of the apply-correlated
hcp-terraformentry (2026-10-18 14:09Z) read 44 applies and still left four account-token edits UNEXPECTED — 2026-10-06 00:10Z and 2026-10-10 21:40Z, HCP Terraform runs whose apply stopped early after writing, whichfilter[status]=appliedexcluded.fetchTerraformAppliesnow reads every run, keeps each one whose apply began (applying-at) and closes its window at the first ofapplied-at,errored-at,canceled-atorforce-canceled-at(the end of the read window for an apply still running); plan-only and discarded runs carry noapplying-atand are skipped. The report names each apply's outcome. - Cloudflare DNS audit: the first live run's lessons (#948). The
workflow's audit step caught the script's exit code on the command itself
(the runner's
bash -eended the step before$?was read, so the first run failed instead of recording exit 2); a manual run can untickopen_issueto report without escalating; and the report prints, for each UNEXPECTED change, the entry's shape (actor field names, request method, user agent; never values), because the first run showed the HCP Terraform token's edits arrive asaccount/api_tokenwith notoken_name. The allowlist is rebound to what the log carries:hcp-terraformis an account-owned token with the Cloudflare Terraform provider's user agent, not the lab host's address, while an apply ofhcw/hcw-azurewas in progress by HCP Terraform's own run record (read with theTFC_TOKENsecret; a user agent alone is a header anyone can send, as CodeRabbit noted); a newterraform-owner-tokenentry keeps the owner's own workstationterraformruns (the 2026-10-06 00:26Z edits) visible under their own name;caddy-acmeis told by the lab host's address in the newCLOUDFLARE_CADDY_HOST_IPvariable, the one address the token's client-IP filter allows, with the token name kept as a second key should the log ever carry it. - Cloudflare DNS audit: a daily poller of the account audit log stands in
for the notification Cloudflare does not have (LAB-1 interim, #948).
Owner decision 2026-10-18: Cloudflare has no notification type for
audit-log or DNS-record events on any plan, so the interim for the Caddy
token's DNS edit on the production zone is
.github/workflows/cloudflare-dns-audit.yml. Daily at 05:50 UTC it runsscripts/cloudflare-dns-audit.mjs, which reads Audit Logs v2 (GET /accounts/{account_id}/logs/audit, every page) from two hours before the previous successful scheduled run (at least 26 hours, at most 14 days; manual runs do not move the resume point), so a failed run widens the next window rather than leaving a gap, keeps the create, update and delete events on DNS records inhybridcloudworks.com, and judges each against an allowlist carried at the top of the script with the reason for every entry: thehcp-terraform-cloudflare-dnstoken's edits, the Caddy token'sTXT_acme-challenge.*.labedits (an A record from it, or a challenge for a site name, is the finding), and the owner's own dashboard edits. An UNEXPECTED change opensCloudflare DNS audit: unexpected record change (<date>), labelledSecurityandlive-check, or comments on the open one; a read that fails (no token, a non-200, a body that is not thesuccess: trueenvelope, more than twenty pages) fails the run rather than passing quietly. Content-free: the token never leaves the environment, record contents are not read into the report, and e-mail addresses are masked.scripts/cloudflare-dns-audit.test.mjscovers each allowlist entry, the refusals, two-page reads, the non-200 and the empty window from hand-written fixtures in the documented shape. The runbook section isdocs/runbooks/edge-dns-verification.md, "The daily DNS audit"; ADR 0032's LAB-1 bullet now names this as the interim. The owner steps are theCLOUDFLARE_AUDIT_LOG_TOKENsecret (Account → Account Settings → Read, the permission the endpoint accepts) and theCLOUDFLARE_ACCOUNT_IDvariable, with the two optional allowlist variables, indocs/standards/required-inputs.md§4.2 and §4.3. - CodeRabbit's approval is the required review (#972, option F). Owner
decision 2026-10-18:
.coderabbit.yamlturns onreviews.request_changes_workflow, so CodeRabbit requests changes while a finding is open and approves the head once every thread is resolved and its pre-merge checks pass, and setsreviews.allow_author_approval: false, so@coderabbitai approvefrom the author (sessions author as the owner's account) cannot stand in for it. TheDefaultruleset'spull_requestrule takesrequired_approving_review_count: 1withrequire_last_push_approvalby the owner'sPUT; the rollback is the samePUTwith the count at 0. - Lab host: a missing sshd drop-in keeps sshd on the loopback while the Arc agent is Disconnected (#1263 follow-up, CodeRabbit on #1263).
- The gap: in its hold state, the Arc gate keeps sshd's bind as the
drop-in has it. With no drop-in to read, it set
hardening_sshd_loopback_onlyfalse, so sshd listened on every address while ufw's rule stayed held. - The fix: a missing drop-in now keeps the role's default, the loopback alone.
- Tests:
tests/ufw-closed-ports.test.shcovers the case, and stops on a drop-in it cannot read (55 checks). - Lab host: sshd's loopback bind follows the Arc gate, as port 22 does
(#1257 follow-up, CodeRabbit on #1261). #1260 bound sshd to the loopback
on every run. Opening 22 at ufw then no longer gave a path in, which broke
two documented routes: a rebuilt host's onboarding over 22
(
Register-LabArc.ps1), and the Web Console reopen in "When Arc SSH is down". - One decision: the hardening role's new
tasks/arc_gate.ymldecides once, beforesshd.ymland on every run whatever the tags, from the Arc agent. ufw's 22 rule and sshd'sListenAddresslines both follow it: closed and on the loopback while Arc SSH is ready, open and on every address while it is not, and left as they are while the agent reads Disconnected. - The listener check:
privilege_checks_public_tcp_portsadmits 22 only on a run where sshd listens on every address. - The arc role's guard: it now also requires a live listener beyond the loopback before Arc SSH is turned off, and that listener must answer with an SSH banner.
- The
sshd -Tread-back: while sshd should listen on every address, it requires an address beyond the loopback. - Tests:
ufw-closed-ports.test.shhas 53 checks, with real listeners standing in for sshd and a bounded wait for each, andsshd-apply.test.shhas 24. The runbook's reopen now rebinds sshd too. - Lab host: turning Arc SSH off never leaves both SSH paths shut (#953
follow-up, CodeRabbit on #1258). Port 22 has been closed at ufw while Arc
SSH is ready since #1258. A run with
arc_incoming_ssh_enabledfalse could close 22 in the hardening role, then clear the Arc agent's incoming ports later in the same run. - Hardening:
hardening_arc_ssh_wanted, which follows that switch ingroup_vars, makes the gate open 22 in such a run. - Arc role: the new
tasks/ssh_off_guard.ymlrefuses to turn Arc SSH off while ufw keeps 22 closed, as in a run narrowed to--tags arc. It reads ufw conservatively: an installed ufw must answer, any DENY or REJECT naming 22 refuses, and only a general allow from Anywhere counts. -
Tests:
tests/ufw-closed-ports.test.shruns both halves against the real ufw (46 checks). -
Lab host: sshd listens on the loopback only (defense in depth after #953 stage 3, #1257).
- In
00-hcw-hardening.conf.j2, addedListenAddress 127.0.0.1andListenAddress ::1so sshd binds to the loopback alone. - On Ubuntu,
sshd-socket-generatorderivesssh.socket'sListenStream=lines from these directives atsystemctl daemon-reload; addeddaemon_reload: truetoReload sshand addedRestart ssh.sockethandler. - Removed port 22 from
privilege_checks_public_tcp_ports(defaults: [80, 443]), turning the loopback bind into a verified assertion intasks/listeners.yml. -
Added
listenaddresschecks intasks/sshd.ymlagainstsshd -T, updatedsshd-apply.test.shandlisteners.test.sh, and updated documentation indocs/runbooks/labs-host.mdand role READMEs. -
Lab host: port 22 closed to the internet once Azure Arc SSH is ready (#953 stage 3, LAB-7). The hardening role's ufw tasks (now
tasks/ufw.yml) delete the allow of each port inhardening_ufw_closed_tcp_ports, 22 on the lab host. A run then readsufw statusand fails if any ALLOW or LIMIT rule still names a closed port: alone or in a list, on any interface or address, or as Ubuntu'sOpenSSHprofile.hardening_ssh_arc_gatecloses 22 only while the Arc agent is Connected with incoming connections on for port 22. While the agent is Disconnected (a drop, or a reset agent being onboarded again) it leaves 22 as it is. Otherwise, as on a rebuilt host thatRegister-LabArc.ps1onboards over 22, it keeps 22 open, and the run says which.tests/ufw-closed-ports.test.shruns the tasks against the real ufw in a scratch network and mount namespace with a stubazcmagent, 36 checks, in CI'sansible-lint (lab-host)job.alert-lab-ssh-loginnow pages any login that does not arrive through Arc (lab_ssh_loopback_onlydefaults to true). The query change is declared for the plan check, and Arc SSH's two applied creates are pruned from it. sshd still listens on every address behind ufw, so 22 stays inprivilege_checks_public_tcp_portsuntil #1257 binds sshd to the loopback. The runbook adds "Closing port 22: the first run" and "When Arc SSH is down", the Web Console reopen. -
Lab host: the play also fails if the host daemon publishes a port beyond the loopback (#958 follow-up). A socket read sees a published port only through Docker's userland proxy, a daemon setting. So
privilege_checks/tasks/listeners.ymlalso reads thenattable'sDOCKERchain, IPv4 and IPv6, and refuses anyDNATrule without a loopback destination. On 2026-10-17 the host's five rules were all-d 127.0.0.1/32.tests/listeners.test.shnow has 23 checks, and the check refuses a Docker firewall backend other than iptables, which its rule read does not cover. - Lab host: the play fails if anything listens beyond the loopback but sshd
and Caddy (#958, LAB-12). The last check of every run
(
roles/privilege_checks,tasks/listeners.yml) reads every TCP and UDP listener withss, the rootless sandbox's forwarded ports included. - What fails: anything not on
127.0.0.0/8,::1or an IPv4-mapped loopback address, unless it is TCP onprivilege_checks_public_tcp_ports(22, 80 and 443). The failure names each one. - UDP and port 22: no UDP port is public now that Caddy serves no HTTP/3, and port 22 leaves the list when it closes (#953, stage 3).
- The isolation chain: the other half of LAB-12's acceptance is the host firewall's read-back (#1236), which fails the play when the live chains differ from the files.
-
Tests:
tests/listeners.test.sh(15 checks, in theansible-lint (lab-host)job). On the lab host's ownssoutput of 2026-10-17 it refuses only Caddy's UDP 443, which the next bootstrap removes, and passes without it. The runbook gains "Listeners beyond the loopback". -
Audit finding PLAT-8: Cosmos DB export immutability, dedicated state container, 90-day security log retention & PII data-plane auditing (#968).
- Added time-based WORM immutability policy
azurerm_storage_container_immutability_policy.cosmos_exportininfra/storage.tfenforcing 35-day retention matching theexpire-cosmos-exportlifecycle policy so backup blobs cannot be deleted or overwritten by the application identity or non-admin roles. Parameterized locking viavar.cosmos_export_immutability_locked. - Added dedicated mutable container
azurerm_storage_container.cosmos_export_state(cosmos-export-state) for change-feed continuation tokens (state/${container}.json), preventing daily delta continuation token overwrites from failing against WORM container retention (BlobImmutableDueToPolicy). UpdatedcreateExportBlobStoreinfunctions/src/lib/backup/cosmos-export-edges.jsto routestate/continuation blobs tocosmos-export-state. - Increased Log Analytics workspace retention from 30 days to 90 days via
var.logs_retention_in_days(default 90) ininfra/main.tfandinfra/variables.tf, satisfying compliance retention requirements for diagnostics, security tables, syslog, and activity logs. - Enabled Cosmos DB
DataPlaneRequestsinazurerm_monitor_diagnostic_setting.cosmosininfra/observability.tf, with an ingestion-time table transformation (azapi_update_resource.cosmos_data_plane_table) filtering telemetry to personal data containers (ambassador), providing a compliant 90-day PII access audit trail without exhausting the workspace daily ingestion quota. - Documented quarterly out-of-subscription export strategy, immutability verification commands, and state container isolation in
docs/decisions/0028-cosmos-out-of-account-export.mdanddocs/runbooks/cosmos-restore.md. -
Added unit tests in
scripts/backup-immutability-retention.test.mjs,functions/src/lib/backup/cosmos-export-edges.test.js, and updatedscripts/assert-expected-plan.mjs. -
Coder: one running workspace per learner, and cloud sign-ins kept in memory (#954, LAB-8). Owner decision 2026-10-16, in all three templates (
hcw-lab,amo-guided-lab,na-guided-lab). - Sign-ins in memory: a 64 MiB tmpfs at
/tmp/cloud-creds(0700,noexec,nosuid,nodev), withAZURE_CONFIG_DIR,AWS_CONFIG_FILE,AWS_SHARED_CREDENTIALS_FILEandCLOUDSDK_CONFIGpointing into it. A cloud sign-in never reaches the home volume, and ends when the workspace stops. - One running workspace: on a start build only, the template reads
the owner's workspaces from Coder on the loopback with their session
token (
hashicorp/http3.6.2, locked), failing closed unless Coder answers 200. A precondition refuses the start while another holds the limit: a start pending, starting, running or cancelling, or a failed stop. A failed start does not hold it. - The launcher and the site: a start refused for another lab names
that lab, frames its Stop, and starts this one once the other stops. A
limitedstate reads "Stop your other lab to continue", and the ten-minute cap does not run during that wait. The site's lab panel says one workspace runs at a time and a sign-in lasts until the workspace stops. - Which builds it binds: builds of a template version that carries it. Coder Community's dashboard Start rebuilds a workspace's own version, so the runbook has a one-time owner step after the first publish.
-
Tests:
template.test.mjs(28) andlab-host-launcher.test.mjs, and the coder CI job now runs fmt, init and validate on every template. -
Audit finding PLAT-6: Rebuild inventory outside Terraform, custom roles bootstrap, secret escrow & origin-secret rotation (#966).
- Parameterized Azure custom role definitions: created
scripts/bootstrap-roles.ps1andscripts/bootstrap-roles.shallowing subscription ID injection (-SubscriptionId,-ManagementSubscriptionId) across all five custom roles (keyvault-secret-writer,function-config-refresh,function-settings-reader,static-web-app-deployer,alert-rule-reader), replacing hardcoded subscription GUIDs. - Added unit test assertion in
scripts/terraform-role-definitions.test.mjsverifying that bootstrap scripts cover all registered custom role definitions. - Documented authoritative Where to Re-obtain paths in
docs/standards/required-inputs.md§4.6 covering all 30 Key Vault secrets infunctions/src/lib/secret-catalog.js(including portal URLs, CLI generation commands, Bitwarden item names, and auto-renewal tools). Updated §4.6b to document the five custom roles and bootstrap scripts. - Authored
docs/runbooks/origin-secret-rotation.mddetailing the 3-way lock between Key VaultCF-ORIGIN-SECRET, HCP Terraformcloudflare_origin_secret, and Cloudflare Transform Rulex-hcw-origin-secret, complete with zero-downtime execution order, verification curls, and rollback steps. - Authored
docs/runbooks/cold-rebuild.mdproviding a single ordered, copy-pasteable disaster recovery guide from bare accounts to full production estate operation within the 8-hour RTO objective. -
Updated
mkdocs.ymlnavigation underRunbooks:. -
Secrets: Google API keys and AWS price-list IAM access key auto-renewal (#1028).
- Implemented automated Google Cloud API key renewal in
functions/src/lib/credentials/google-roll.jsvia Google Cloud API Keys API v2 (apikeys.googleapis.com) with verify-before-delete semantics forGEMINI-API-KEY,YOUTUBE-API-KEY, andGCP-BILLING-API-KEY. - Implemented automated AWS IAM access key renewal in
functions/src/lib/credentials/aws-roll.jsusing pure SigV4 signing (node:crypto) with verify-before-delete semantics forAWS-ACCESS-KEY-IDandAWS-SECRET-ACCESS-KEYscoped topricing:GetProducts. - Enforced verify-before-delete: newly minted credentials are functionally probed against their respective cloud services (Gemini models list, YouTube videos list, GCP Billing services list, AWS Pricing GetProducts) AND committed to Azure Key Vault before old credentials are deleted. If a probe fails, the newly minted key is deleted and the existing key remains active and undisturbed.
- Added CLI orchestrator
scripts/roll-cloud-credentials.mjssupporting--target <gemini|youtube|billing|google-all|aws|all>,--dry-run, and--checkmodes with zero CLI argument secret leakage. - Updated
functions/src/lib/credentials/register.jsto setrenewal: 'automation'with rotation commands for all five credentials. -
Added 65 new unit tests across
google-roll.test.js,aws-roll.test.js,roll-cloud-credentials.test.mjs, andregister.test.js. -
Coder: only the
lab-learnersteam signs in, and no sign-in creates an account (#954, LAB-8). Owner decision 2026-10-16. - The allowlist: the
coderrole rendersCODER_OAUTH2_GITHUB_ALLOWED_TEAMS=HybridCloudWorks/lab-learners, andcoder_oauth2_github_allow_signupsisfalse. - The checks:
tasks/signin_policy.ymlrefuses an empty team list, or a team that is not<organisation>/<team-slug>in an allowed organisation spelled the same way, since Coder v2.38.0 checks a team only inside an organisation the learner already passed. - Who is affected: existing accounts, the owner's included, keep
working, and the team is checked at each GitHub sign-in. A learner's
first sign-in matches only an account the owner made with their
primary verified GitHub email; the runbook's new "Coder sign-in:
adding and removing a learner" has the lines, and a suspended learner's
workspaces stop within a minute. Sign-ups off is the normal setting
now, not a kill switch:
group_vars, ADR 0032 anddocs/standards/required-inputs.mdsay so. - No account, no route: sign-ups off does not cover a deployment's
first account, which v2.38.0 lets through from GitHub or
/setupand makes the Owner. Before Coder starts, the role counts the accounts in its database. With none, it takes Coder's Caddy route down and reloads Caddy then and there. Once Coder is up, a 404 from/api/v2/users/firstfails the play. The runbook's "A Coder with no account yet" has the owner's measured recovery, andscripts/lab-host-coder-first-user.test.mjsholds it. - Runbook token lines: they strip the carriage return with
tr -d \\r; one backslash had deleted everyrfrom the token. -
Tests:
roles/coder/tests/coder-signin.test.sh(23 checks) in theansible-lint (lab-host)job. -
Secrets: Cloudflare API token auto-renewal via roll-token API (#1027).
- Implemented automated Cloudflare API token renewal in
functions/src/lib/credentials/cloudflare-roll.jsand CLI orchestratorscripts/roll-cloudflare-tokens.mjs. - Supports all three estate targets:
tfc-azure-cloudflare-api-token(hcw-azureHCP Terraform workspace),tfc-lab-cloudflare-api-token(hcw-labHCP Terraform workspace), andlab-caddy-cloudflare-api-token(vault_cloudflare_api_tokenin lab host Ansible Vault). - Enforces verify-before-apply semantics: verifies the newly generated token secret against Cloudflare's
/user/tokens/verifyendpoint before committing to HCP Terraform or the lab host vault. - Updates HCP Terraform sensitive workspace variables via the JSON:API endpoint (
application/vnd.api+json). - Updates the lab host Ansible Vault via
hcw-vault-set, piping secrets strictly to standard input. - Updated
functions/src/lib/credentials/register.jsto setrenewal: 'automation'for the three Cloudflare tokens. -
Added 33 unit tests across
functions/src/lib/credentials/cloudflare-roll.test.js,functions/src/lib/credentials/register.test.js, andscripts/roll-cloudflare-tokens.test.mjs. -
Lab host: Azure Arc SSH as the owner's way in; port 22 stays open (#953, LAB-7 stage 2). Arc SSH carries the session through an Azure Relay connection the host's Arc agent dials out to, so sshd needs no inbound port. sshd still asks for the pinned key. Port 22 closes in a later change, once the owner has connected this way and through the Web Console.
- Azure:
infra/lab-hybrid.tfdeclares the Arc machine's default connectivity endpoint and its SSH service configuration, port 22 only (Microsoft.HybridConnectivity2024-12-01). They are gated on the machine existing, grant no role, and are declared in the plan check. - The host: the
arcrole turns on the agent's incoming connections for port 22 alone. It fails closed unless the extension allowlist is still the Azure Monitor Agent alone, and the nightly drift check sees each setting.arc_incoming_ssh_enabled: falserestores LAB-6's state. - The desktop:
Connect-Lab.ps1 -Arcchecks az, the ssh extension, the sign-in and the service configuration before writing anything. ItsProxyCommandhelper gets fresh relay credentials on every connection, deletes them from disk before the connection starts, and passes them to the proxy in its environment alone.HostKeyAliaskeeps the host key already accepted. Without-Arcthe direct block returns. - Docs: ADR 0032's amendment of 2026-10-16, the runbook's "Connect through Azure Arc" and the architecture page.
- Tests:
scripts/lab-host-arc-ssh.test.mjsholds the port, names and api-version together across infra, the role and the script, andConnect-Lab.Tests.ps1runs the generated helper end to end against a stand-in az and proxy. - Lab host SSH: one user, one pinned key, public key only, behind a lockout guard (#953, LAB-7 stage 1). Port 22 stays open in this stage; Arc SSH and closing 22 follow.
- The key:
group_varspinshardening_admin_authorized_keysto the owner's workstation key, and thehardeningrole writeshcwadmin'sauthorized_keysfrom that list alone. Root's is emptied, and the copy-from-root fallback is gone. - The guard: before writing, the role stops on an empty list, an entry that is not one public key, or a list without the key of the most recent accepted login (read from 14 days of the journal).
- sshd: a drop-in adds
AllowUsers hcwadmin,AuthenticationMethods publickey, no agent, stream-local or tunnel forwarding (local TCP forwards stay),LoginGraceTime 30,PerSourcePenaltyExemptListfor the loopback that Arc SSH will arrive from, andLogLevel VERBOSE. It is validated, read back withsshd -Tand put back on failure. sshd is reloaded, never restarted. - Order: a pending-reload marker keeps root's keys until sshd has loaded the drop-in, even after an interrupted run.
- Connect-Lab.ps1: stops writing root's
authorized_keys, and prints thegroup_varsline that pins a key. - Accepted risk: passwordless sudo stays, with its compensating controls in the role README; hPanel's Web Console is the break-glass.
- Tests:
admin-keys.test.sh(33 checks) andsshd-apply.test.sh(21, as root) in theansible-lint (lab-host)job, andlab-host-ssh-hardening.test.mjs.lab-host-ssh-login-alert.test.mjsnow holds the pinned keys to the alert's fingerprints unconditionally. - Add-on images: SLSA build provenance attestation verification before pull (#1069, part 1).
The
addonsAnsible role (lab-host/ansible/roles/addons) now verifies the SLSA build provenance attestation of each deployed add-on image against Sigstore's public transparency log and the expected GitHub Actions workflow identity (attestation_identity_regexp) usingcosign verify-attestationbefore callingdocker_image_pull. A missing attestation pattern, mismatched identity, or unreachable transparency log fails the run closed, preventing untrusted or unauthenticated images from being deployed. Sigstorecosign(v3.1.3) is pinned and SHA-256 verified inbootstrap.shandgroup_vars/all.ymland tracked upstream viascripts/lab-pins-upstream.mjs. - Visitor lab sessions: the broker sends Coder nothing it cannot vouch for, and follows no redirect (#1403, the items found while the sweep timer was built). Every call
functions/src/lib/labs/visitor-session.jsmakes to Coder is made withredirect: 'error': the broker token travels in aCoder-Session-Tokenheader thatfetchkeeps on a redirect to another site, so a Coder address that answered with one would have sent the token wherever it pointed, and the sweep now runs unattended with it. A value read from a session record is written into a Coder path only when it has the shape of what it claims to be: a Coder id must be a UUID (isCoderId), a visitor's user namev-and twelve hex characters (isVisitorUsername), and a workspace name one of the labs' (isLabWorkspaceName); the old rule, any one path segment that was not.,..orme, would have sent a stored value that was another person's name. Before a user is deleted it is read back by its id (GET /api/v2/users/{id}, the page read 2026-11-03), and the delete is sent only when Coder's answer is that id under a visitor's name; Coder having no such user counts asgone, like a delete that found nothing, and is said in the log the same way, and any other answer leaves the user for the next try. The test fixtures' Coder ids are UUIDs now (functions/test/coder-ids.jsmakes the same one from a label every time), and tests break each rule on purpose: a plausible name as an id, another user answered for the read-back, a name that is not a visitor's, a redirect, a workspace name that is not a lab's. - The Docker Sandboxes template gets the same Azure CLI Python fixes as the lab image, and is now built and scanned (#1473).
lab-image/sandbox-template/Dockerfileis the recipe the Docker guide page tells a learner to build for "Run an agent against your landing zone"; it installs the same Azure CLI release as the lab image'sfulltarget, but until now without the package replacements inAZURE_CLI_PYTHON_OVERRIDES(lab-image/versions.env), so a learner's build still carried the certifi, PyJWT and urllib3 versions the weekly scan flagged, and nothing built or scanned it. The recipe now does what thefulltarget does, in the oneRUNthat installs the CLI: the downgrade guard, the wheels downloaded with the CLI's own pip in hash-checking mode (it has no fetch stage), an offline install andpip check.publish-lab-image.ymlbuilds it on every pull request that toucheslab-image/and smoke-tests it offline (the replaced versions read back,azandterraformat their pins, theagentuser), andlab-supply-chain.ymlbuilds it frommaineach week and scans it with Trivy beside the published images, saying in its issue when the build itself failed.scripts/lab-image-azure-cli-overrides.test.mjsholds the recipe to the same order and the workflow to the build, andlab-pin-pr-workflows.test.mjsholds the scan. - A day on which the Log Analytics workspace hits its cap is a named alert,
alert-logs-overquota, and the personal-data access trail is kept in full (#1530).alert-logs-capacityfired at 80% every afternoon that week and was read as weather; nothing said that from 20:33 until 08:00 UTC every log-based alert in the estate was blind. The new rule reads theOverQuotaline Azure writes to_LogOperationwhen collection stops (Microsoft's own daily-cap query), fires from inside a capped workspace because that table is not billable, is stateful across the 08:00 UTC reset, and pages at severity 1 through the existing action group. With the request-log filter above, every read and write onambassador,doc_vaultandbrain_recordsis logged for 90 days with the caller's identity, which the data-subject runbook now points at. - The Function App's Cosmos data role gains a grant on the
hcwdatabase alone, the first of two steps that narrow it from the whole account (#1469). The backend reads Cosmos through one data-plane role (Cosmos DB Built-in Data Contributor) granted on the whole account. Cosmos can grant the same role on one database instead (scope<account id>/dbs/<database>), and at that scope the SDK can still read the database, list its containers and work inside them, which is everything the app, the six change-feed functions, theirleasescontainer and the nightly export do. The move takes two apply runs so the app never has a moment with no grant: this step addsazurerm_cosmosdb_sql_role_assignment.func_cosmos_db["hcw"]ininfra/cosmos.tfbeside the account-widefunc_cosmos, whose address is deliberately untouched (acountormovedon the one grant the site runs on would be an address change the plan check reports, and a wrong move is the gap this avoids); a later pull request deletes the account-wide block. Onlyhcwgets a grant:hcw_code_brainleft with #1480. The plan check declares the one create,infra/tests/guards.tftest.hclholds the new grant to the database scope and the Data Contributor role while the account grant keeps its scope,scripts/assert-expected-plan.test.mjsholds the declarations to the database map, the deployment runbook carries the owner's steps and checks for both apply runs, and ADR 0036 records the narrowing as in progress. - The custom role definitions no longer carry the owner's subscription id,
and the redaction gate now reads
infra/, the workflows and the operator scripts (#1540). Five of the role files underinfra/roles/named a real subscription in theirassignableScopes, in a public repository whose rule is that a real tenant, subscription or application id never goes in a tracked file. The files now carry the placeholder form the sixth file already used (...0001for the workload subscription,...0002for the management one), andscripts/bootstrap-roles.shand.ps1, which always replaced that field at create time, now also find both real ids themselves: the workload subscription fromaz account showand the management one fromaz account listby its name, instead of carrying the management id as a default in the script. Both scripts refuse a placeholder or a non-GUID and end with a read-back table of everyHCWrole and its real scopes, which is the evidence for the owner's re-run.scripts/docs/check_redaction.pyonly ever readdocs/and the root documents, which is how this went unnoticed; it now also scansinfra/roles/*.json,infra/**/*.tf,infra/**/*.tfvars*,.github/workflows/*.ymland the top-levelscripts/*.ps1and*.shfor non-placeholder GUIDs, with the public constants those files legitimately hold (four Microsoft built-in role and policy ids, one Cloudflare DNS ownership token, the two synthetic browser-test ids) on its allow-list with their reasons, andrepository-policy.ymlruns it on every pull request (the docs workflow only runs when a docs path changes).scripts/docs-redaction.test.mjsfails on a role file with a real-looking id, on each new scan path, and if that step leaves the policy workflow. - The Entra scripts look the API's client id up instead of carrying it,
and the redaction gate reads the rest of the tree
(#1540).
scripts/cutover/01-entra-api.ps1,02-entra-spa-client.ps1,03-entra-dev-client.ps1,scripts/rollback/restore-admin-signin.ps1andscripts/lab/Register-LabAgent.ps1carried the HCWSite API's real application id as a parameter default, the rollback script the tenant id too, and their tests and two frontend tests held the same values as fixtures, one folder below where the gate read. Each script now takes-ApiAppId, thenHCW_API_APP_ID, then the one registration namedHCWSite APIin the signed-in tenant (scripts/lib/Resolve-EntraApiAppId.ps1; the lab script's ownResolve-LabApiAppId, tested with Pester), stopping when none or several match; the rollback script compares-TenantIdorHCW_TENANT_IDonly when one is given, and otherwise the lookup is the tenant check. The gate now reads every.ps1and.shunderscripts/,lab-image/**/*.sh,lab-host/**(yml, yaml, sh, j2),functions/src/**/*.jsandfrontend/src/**/*.{js,jsx}. Every GUID those paths held was classified: the real ids went; the made-up fixture ids were rewritten to a first group of one repeated digit (99999999-...), which the gate now recognises along with the placeholder and the browser tests'e2e00000-...family; the Defender WSL walkthrough's and simulator's sample device ids became obviously fake; and the four Azure Policy built-in definition idsestate.jsexcludes, the two UUIDv5 namespaces the draft ids are hashed under, the three UUIDv5 values their tests assert, and three public URL names (Amazon Music's podcast listing, two ElevenLabs preview objects) went on the allow-list with their reasons. The four gitleaks fingerprints for the removed defaults left.gitleaksignore, and the rollback runbook, the lab-host runbook and ADR 0006 say where the id comes from now. - The Function App's account-wide Cosmos grant is removed; the identity
keeps the
hcwdatabase grant alone, the second of the two steps (#1469). Step 1 (#1531) addedazurerm_cosmosdb_sql_role_assignment.func_cosmos_db["hcw"]beside the account-widefunc_cosmosand applied on 2026-11-04, after which both grants were listed on the identity and the site answered as before. This step deletes thefunc_cosmosblock frominfra/cosmos.tfand declares the one destroy inscripts/assert-expected-plan.mjs, so the plan is that destroy beside the permanent diff and nothing else. Before the block went, the code was read for anything that needs the account scope: nothing lists the account's databases (functions/src/lib/cosmos-client.jsopensCOSMOS_DATABASEby name, the six change-feed functions and theirleasescontainer bind to that same database, and the nightly export reads containers through the same client), which is the only thing the account scope adds per Microsoft's data-plane security reference.infra/tests/guards.tftest.hclnow refuses an account-scoped grant for the identity instead of requiring one,scripts/assert-expected-plan.test.mjsholds that the block is gone and its delete declared, and that no Cosmos role assignment ininfra/is scoped to the account id, the deployment runbook marks Step 2 as the current step with the owner's checks and the rollback (revert the pull request with the restored grant's create declared, so its plan is one create the plan check accepts), and ADR 0036 records the narrowing as complete once that apply runs.
Fixed¶
- The weekly lab pin bot no longer reports the Python base image as behind when it is current (#1426). The
--bump image-basecheck decided whether the Python line tag (python:3.14-slim-trixie) and its patch tag (python:3.14.10-slim-trixie) agree by comparing their whole image lists (an index, one image per platform). The line tag still lists a riscv64 image on CPython 3.14.9, so the two lists differed and every run posted a "have not settled" note, although the pin was the patch tag's own digest and the linux/amd64 image (the only one the lab image is built for) was the same in both.scripts/lib/lab-pin-bumps.mjsnow compares the two tags' linux/amd64 images, says nothing when the pin already is the patch tag's digest, still pins the patch tag's index digest when it moves, and stops with a note when either tag lists no linux/amd64 image. Found by the review of #1466; four cases inlab-pin-bumps.test.mjs. - Visitor lab sessions: the follow-ups from two reviews are fixed or decided (#1400, #1403). All in
functions/src/lib/labs/visitor-session.js, withLABS_VISITOR_SESSIONS_ENABLEDstill off. A visitor's daily start is now spent only when a new lab is really made: pressing start again while their session runs hands it back for free, a start refused because the lab is full costs nothing, and a start that fails in Coder gives its start back (giveBackDailyCapinfunctions/src/lib/daily-cap.js, a decrement that cannot go below zero). One visitor may ask the human verification check at most 10 times and read the status or stop at most 60 times in ten minutes, per Function App instance, before anything is read. The check's failure detail goes to the log, and the visitor gets the lab submission's plain sentence. The three routes refuse a request that did not come through our Cloudflare whateverNODE_ENVsays. A start writes aprovisioningrecord before its first call to Coder, so a call that never answers, or a session that cannot be saved after Coder made it, still leaves a record the sweep cleans up from (it looks a user up by the name the broker chose,GET /api/v2/users/{user}). Waiting sessions whose workspace is not confirmed as deleted count against the concurrent limit, drop the lab address, and record whether each delete was accepted (ok) or answered 404 (gone, also logged). After three refusals of the user delete in a row the workspace delete is asked for again, and a given-up clean-up gets another round an hour later, up to 24. The stop answers acleanupfield (done,pending,failed) beside its old body. After review, starts that arrive at the same moment can no longer get past the limits: a per-visitor start lock and one slot document per allowed lab, each taken with a create the database refuses when it exists, and slots that repair themselves after a crash. The list of labs is read again after a sweep the start waited for. A daily start is given back exactly once, with one refund document per session (visitor-refund:<id>, a second write of it is refused) that raises the visitor's limit for the day, instead of a decrement; a give-back that fails is tried again by the sweep before the record is saved as over. After a second review, a start also reads the list again once it holds its lock (so a session finished meanwhile is handed back), counts sessions saved before slots existed (past their hour included), and looks a user up after a Coder 408. After a third review, every holder owns one of the slots: a holding session that no slot names is given one before a start takes its own, and slot documents no longer age out. A user Coder plainly refused is never looked up by name. A clean-up given up for good stops counting against the limit. The pane shows the server's sentence for a refused start or status read, and reads the status again after409 VISITOR_START_IN_PROGRESS;.azure/api-surface.jsondescribes all of it. Coder's response text no longer reaches error messages or logs. The tests now run the module's real SQL (runCosmosQueryinfunctions/test/cosmos-predicate.js) and the test store can age records out by theirttl. Still the owner's: whether a session should belong to a secret rather than an address (#1400, item 4), and the broker token's Coder permissions (#1403, item 4). Documentation: the lab host runbook, "Visitor lab sessions". Nothing was run against Azure, Cosmos or Coder. - The weekly lab pin bot now says when it could not read a publisher, and tries a failed read again first (#1447). A product whose release list could not be read used to drop out of the host pull request with no word, so a passing hiccup delayed its update by a week unnoticed. It now appears under "Not bumped" in the pull request body and the notes file (which also feeds the weekly issue), with the address and the status it got, so "no newer release" and "could not read" look different. Every read the bot makes (
scripts/lab-pins-upstream.mjsandscripts/lib/lab-pin-bumps.mjs) that fails with a server error (5xx), a 406 or no answer at all is tried twice more, after pauses of 2 and 5 seconds, with the same address and headers, before it counts as failed. "No answer" now includes a publisher that has not replied within 30 seconds (each try has its own limit, and one read gives up after 60 seconds in all, so a stalled publisher can no longer hold the job for Node's five-minute default) and a connection that drops while the reply is still arriving. Every such failure, a reply that is not JSON, and a reply with no stable version in it now name the address. When the unreadable publisher is the only host item that week, no pull request is opened (there is no edit to put in one), and the weekly issue carries the note. Tests cover a publisher that fails once and is bumped, and one that keeps failing and becomes a note with no edit. - AI Engine model refresh, routing display and cost reporting are corrected (#1437). Provider lists refresh concurrently under one deadline per provider, incomplete pagination preserves the previous list, and routing reloads after catalogue changes. Planned tasks are distinguished from unavailable active tasks; unknown prices remain unknown, small costs remain visible, and Gemini 3.8 Flash TTS is the reviewed multi-speaker recommendation, with structured speaker annotations, verified promotional price expiry, and 25 output audio tokens per second; explicit saved selections remain intact. See model review.
-
Ambassador links stay out of the public footer, and readiness respects qualification periods (#1437). The hard-coded GitKraken Ambassador footer credit is removed. Application dates take precedence over recurring quarter/year windows in the dashboard and workspace; program details use the current upkeep period. Footer links wrap, program cards have more room, and ambassador dialogs and forms fit mobile screens.
-
A visitor lab session is saved as over only once Coder has confirmed the clean-up, and one Coder has not confirmed is kept and retried, not forgotten (#1388). When a visitor pressed End session, or the sweep found a session past its hour,
functions/src/lib/labs/visitor-session.jsasked Coder to delete the workspace and the user, threw away whether Coder accepted, and saved the record asendedorexpiredanyway; nothing looked at it again, so a delete Coder refused was never retried. Coder deletes a workspace with a job that finishes later and refuses (HTTP 417) to delete a user who still has a workspace, so the user delete straight after an accepted workspace delete is refused for most sessions: a workspace that was never deleted keeps one of the host's 5 workspace slots, and the user stays behind. Found by two read-only reviews of the #1383 fix, from the code and the Coder source and not run against a live Coder; the refusal of the user delete is not confirmed on the live host. Now a session is first claimed with a conditional write (activetoending, with a lease), so only one request calls Coder (two stops at the same moment make one set of Coder calls). It is saved asendedorexpiredonly when both deletes are confirmed (accepted, or 404 or 410 because already gone). Otherwise it is kept ascleanup_pending, with which half is done (workspaceGone,userGone) so the workspace delete is not asked for twice, and tried again after 2, 4, 6 and 8 minutes by the sweep or by a later stop, then markedcleanup_failedafter the fifth try, which nothing retries and which keeps its Coder ids for 7 days for a person to finish. A claim whose holder died is taken over by the sweep (or a later stop) after 5 minutes. The sweep (reapExpiredVisitorSessions) now also lists the waiting sessions, returns{ reaped, finished, pending, failed }, takes up at most 10 sessions a time, and runs from the status and start routes at most once a minute per worker. It runs only when someone visits (nothing else triggers it, and the status route answers before it sweeps while the switch is off), so a waiting record is kept for 7 days instead of the usual 2 hours; a timer that runs the sweep on its own needs the owner's decision and is in #1403. A waiting session does not count asactive, so the visitor is not shown it and may start another. A session is also no longer saved as over when the broker is not configured but the session has something in Coder to delete. A start that fails after Coder made the visitor's user (the workspace call timed out, which cancels only our wait while Coder can go on and make the workspace, or was refused) is now kept as acleanup_pendingnote before the 502 is answered: the user's id and name and the workspace's name, no workspace id, no visitor hash (so no visitor can reach it), and a first try after 2 minutes. For a record with no workspace id the sweep looks the workspace up by owner and name (GET /api/v2/users/{user}/workspace/{workspacename}, Coder's API reference, read 2026-10-26) and deletes it only when its name is the expected one and its owner is that user; one it does not find counts as gone for that try only, and the user delete, which Coder refuses while the user still has a workspace, is the confirmation. Every Coder call now has a timeout, 10 seconds for a delete and 30 for the calls that provision a session, so a hung Coder cannot hold a request open; ids are written into Coder's URL paths as one piece each. Logs say only what Coder answered for each half (for exampleuser HTTP 417) and which try it was, never an id. Tests cover Coder refusing (500, 417, timeout, network failure), a later stop and the sweep each finishing it, 404 and 410 counting as done, the bound and the growing wait, the lease takeover, two stops and two sweeps at the same moment, a Coder that never answers, and the throttle; checked by ten changes to the code, the first of them putting the old behaviour back (each makes at least one test fail)..azure/api-surface.json, ADR 0032's amendment anddocs/runbooks/labs-host.md(the statuses, a query for finding stuck sessions, numbered steps for finishing one by hand, rollback) describe it. Two independent reviews before the pull request found no wrong delete, no missed ownership check and no harmful second delete; what they found beyond this change (a delete build that fails after being accepted ends ascleanup_failed, a user create that got no answer leaves no note because its id is unknown, the 404 reading and the token fallback, and more) is in #1403. Built on the #1387 change. Not yet live: the owner deploys the functions. KeepLABS_VISITOR_SESSIONS_ENABLEDoff until then. - Visitor lab sessions now tell visitors apart: each visitor has their own daily limit and can read and end only their own session (#1387).
functions/src/lib/labs/visitor-session.jsasked the client-identity helper for afromRequestmethod thatcreateClientIdentity()has never had (it hasanonymousKey(request)andtrustedClientIp(request)), so in production the lookup was skipped and every visitor was filed under the textunidentified. WithLABS_VISITOR_SESSIONS_ENABLEDon, that meant one shared daily limit of 2 for the whole site;GET public/labs/visitor-sessionanswering any caller with the id and address of the live session (it has no Turnstile, origin or rate check);DELETE ?sessionId=then ending that session, and a new visitor being handed the first visitor's session; and Turnstile (Cloudflare's bot check) never being told the caller's address. Found by a read-only review of the #1383 fix, from the code and not run against a live deployment; whether the setting is on in production was not checked. The three routes now read the caller the waylib/labs/public-submit.jsdoes:identity.anonymousKey(request).key(a hash of the address Cloudflare reports) files the session and the daily limit, andidentity.trustedClientIp(request)goes to Turnstile. A request that cannot be shown to have come through our Cloudflare (no origin secret), or that carries the secret but no usable client address (which the helper would otherwise hash into one shared bucket, the same fault under a new name), is answered403 FORBIDDENbefore the store, Turnstile or Coder is used, and the log line says only that a request was refused. Only the helper's own refusal (its error now carriescode: 'ORIGIN_NOT_VERIFIED') is answered that way; any other error, such as aTypeErrorfrom a method the helper lacks, is thrown on and shows as a server error. The status route describes only the caller's own session. The stop route ends a session only for the caller it belongs to, and answers any other caller404 Session not found, the same words as for an id that never existed (or one that is already over), so holding an id is no longer enough. People behind one shared network address count as one visitor, as they do for every other anonymous limit here. A session already stored underunidentifiedcan be neither read nor ended by anyone and is expired by the sweep when its hour is up.createVisitorSessionHandlersnow refuses, when it is built, an identity withoutanonymousKeyandtrustedClientIp(assertClientIdentity, new inlib/auth/client-identity.js), where it used to carry on with no identity at all. So the same mistake cannot come back unseen:functions/src/lib/auth/client-identity-calls.test.jsreads all offunctions/srcand fails any property read from anidentitythat the real helper does not have (the list of names is taken from the real object, not typed out), and any call ofanonymousKey,trustedClientIp,viaCloudflareorrateLimitKeymade with no request, naming the file and line; it also fails a test that writes its ownidentity = { ... }stand-in with a method the real helper lacks. The visitor tests, and the lab-start counter's tests invisitor-session.counts.test.js(which had copied the same stand-in), now build the realcreateClientIdentity()and send requests with the origin secret and an address, where they used to hand the code a stand-in that had thefromRequestmethod; new cases cover two callers (different keys, a daily limit each, Turnstile given each caller's address, neither able to read or end the other's session, nor to learn that a finished one existed), a request without the origin secret or without a client address, and a run of all three routes on an identity that throws on any property the real helper lacks, whatever the code calls it. Checked by putting the old lookup back (the identity guard and five visitor tests fail), taking the ownership check out of the stop route (four fail), letting an unverified request through asunidentified(four fail) and not giving Turnstile the address (one fails)..azure/api-surface.jsonand the visitor sessions section ofdocs/runbooks/labs-host.mddescribe the new answers. Not yet live: the owner deploys the functions. KeepLABS_VISITOR_SESSIONS_ENABLEDoff until this is deployed. - Ending a visitor lab session, and the sweep that expires old ones, now really save the session's record (#1383).
functions/src/lib/labs/visitor-session.jssaved a changed session withreplaceDocIfMatch('lab_jobs', session.id, {...}, session._etag): an id, the document, then an ETag (the version stamp the database checks, so a write made from an out-of-date copy is refused). The real function incosmos-client.jstakes the whole document with its_etaginside it, then an options object, so both saves failed. The sweep (reapExpiredVisitorSessions) swallowed the error: an expired session stayedactiveand was never counted as reaped. The stop route (DELETE public/labs/visitor-session) deleted the Coder workspace and user first and then threw, so the visitor's "End session" ended in an error (the Functions host answers an uncaught error with HTTP 500) for a workspace that was already gone, and the record kept sayingactive, so the session still counted against the visitor limit (default 2) and starting again would hand back the dead workspace's address until the session's hour ran out. Not checked against a live deployment: this is from the code, the function's signature and tests that fail on the old calls. Both saves now go through one helper,finishSession, which callsreplaceDocIfMatch('lab_jobs', { ...record, status, endedAt }, { partitionKey })and, when the save loses a race (a second stop, or the sweep, wrote first: a 412, or a 404 because the record aged out), reads the record again: alreadyendedorexpiredmeans someone else finished it and the loser has nothing to do; stillactivemeans something else changed it and the save is tried again on the new version, up to three times, then the stop answers 409 and leaves the recordactive. A second stop of a session that is already over now answers200 { ok: true, status: 'ended' }without calling Coder again. The sweep now returns{ reaped, failed }; a save that fails for any reason other than a lost race, a record that keeps changing, a sweep that cannot list the expired sessions, and a Coder clean-up call Coder did not accept are each logged (by kind only: no session id, no error text, no Coder id) where the request has a log, which is the start route; the status route's background sweep still has none, because it runs after its answer is sent. Not changed here, and now visible: a session is saved as over even when Coder refused a clean-up call, and then it is not retried (before this fix the record stayedactive, so later requests re-ran the deletes by accident); making the final status depend on a confirmed clean-up is #1388. Also found by the review and filed separately, not fixed here: the code asks the client-identity helper for afromRequestmethod that does not exist, so every visitor is treated as the same person (#1387); keepLABS_VISITOR_SESSIONS_ENABLEDoff until it is fixed. So the same mistake cannot come back unseen:functions/src/lib/cosmos-call-shape.jsis the one definition of the shapereplaceDocIfMatch(containerName, document, options)takes (a document object with a stringidand the_etagit was read with; options only{ partitionKey }); the realreplaceDocIfMatchcalls it first and refuses any other shape with aTypeErrorthat says what to pass and names no value, before the SDK is touched, and the shared test storefunctions/test/memory-store.jscalls the same check, where it used to accept any argument order (the visitor tests' own store did, which is why they passed). I read the 37 calls elsewhere infunctions/src; each passes a stored document (so one with its_etag) and{ partitionKey }or nothing, so none is affected.functions/src/lib/replace-doc-if-match-calls.test.jsreads the source text and fails anyreplaceDocIfMatchcall with more than three arguments, an id as its second argument or an ETag as its third, naming the file and line, so a call that no test reaches is still checked (it flags the two old calls, at lines 256 and 615 of the old file). The visitor tests now run on the shared store, with new cases for the save itself, the arguments the store receives, a second stop, the sweep winning or losing the race, a record that keeps changing, a record that aged out, two stops at the same moment, two sweeps at the same moment, and a store that cannot save. Checked by putting the old calls back (the visitor tests that save a session and the source-text test fail) and by taking the new check out ofreplaceDocIfMatch(the five tests that call it with a wrong shape fail)..azure/api-surface.jsondescribes the stop route's answers. Not yet live: the owner deploys the functions. - Add-on follow-up to #1351: the status-route inventory names Cloud Crawler's
setting, and the labs-host page stops saying the add-ons are not on the
host. CodeRabbit's two findings on #1351 were real and the PR merged before
they were answered.
.azure/api-surface.jsonnow listsADDON_CLOUD_CRAWLER_URLamong the settings the status route reads and says it is not provisioned while the row iscoming. Indocs/architecture/labs-host.md, the Tool add-ons row says that "not yet on the host" was the state at the 2026-10-08 review and that all three add-ons answered on the live status route on 2026-10-24, and the Applied state row for ADR 0035 now records the merge dates (#1043 on 2026-10-09, #1089 on 2026-10-12) instead of "not merged yet". No host run date or commit is written, because the repository does not hold them; the host's own record is still to be read (#1355). CodeRabbit's review of this pull request added two more corrections to the same page: a stale[REVIEW REQUIRED]marker on the ADR 0035 amendment is replaced by the owner's confirmation of 2026-10-10 (#1044, ADR 0035 decision 8), and two statements that said more than the evidence are reworded: the status route showing an add-on answering is not the host having applied the commit, and Cloud Crawler having nogroup_varsrow is not a check of the host. -
Interviews: the header link moved to the Azure landing page, interview pages keep the Azure menu, and a route check that failed now passes (follow-up to #1337). CodeRabbit's two findings on #1337 were real, and a browser run found a third problem beside them. (1) The top menu is a fixed row of nine 100-pixel slots whose divider must sit in the same place on every provider; the tenth link #1337 added for Azure overflowed it at normal laptop widths and moved the divider. It was invisible on the live site because no interview exists yet, so it would have appeared the day the first one was added. The link is now an Interviews section on the Azure landing page (
/azure), shown only once an interview exists, and the menu stays at nine links. (2) The header finds the provider from the URL alone, andinterviewswas not a known page type, so on/azure/interviewsthe Azure menu was replaced by the generic provider list. It is now a page type inrouteFactory.ts, assandboxesis for Docker, and the Azure menu stays on every interview page. (3)npm run validate:routesfailed on two hard-coded/azure/interviews/...paths in a test; they now come frominterviewPath. That validator is not run by CI, which is why nothing flagged it. Checked in a real browser at 1280 and 1100 pixels: nine links on every Azure page, interview pages included, the divider at the same position on all of them, no overflow, no errors. -
The Linkie Hub follows Linkie's rename of profiles to pages. An October 2026 notice from Linkie to API users says
GET /api/v1/profilesis nowGET /api/v1/pages,/profiles/{id}/postsis/pages/{id}/posts, the analytics parameterlink_in_bio_idispage_id, and the key scopesprofiles:readandprofiles:writearepages:readandpages:write. Linkie's own API reference, read on 2026-10-23, shows the new endpoints, thedata.pageslist and thepage_idparameter; the scope names come from the notice alone, because the reference does not list them. The hub's Links, Analytics and Settings tabs and the Connections test used the old names, andlinkieProxy's allow-list only let the old paths through, so they would have stopped working as soon as Linkie retired them. The allow-list, the frontend paths, the list reader (data.pages) and the analytics parameter now use the new names; the old paths are no longer allowed. The allow-list moved intofunctions/src/lib/integrations/linkie-allowlist.jsso a frontend test can run every path the hub builds through the real matcher, and a rename that moves one side and not the other fails there (ci.yml's frontend filter lists the two functions files that test reads, as the repository's CI-filter test requires). The API key may need creating again: a key that only has the old scopes answers 403 "Insufficient scope". Linkie's key form has three separate permissions, and the hub needs Linkie pages Read Only, Automated posts Read & Write and Analytics Read Only (a first draft of these docs asked for pages Read & Write alone, which would have passed the connection test and still refused posts and traffic; CodeRabbit caught it).docs/standards/required-inputs.mdand the credential rotation schedule say how, with the exact Playground URL. Function names in the hub still say "profile"; a profile there is what Linkie now calls a page. -
A healthy Functions deploy no longer goes red because the temporary firewall rule reached the origin one front end at a time. The deploy opens an allow rule for the CI runner's address, and App Service applies it to its front ends gradually. The workflow waits for
/api/healthto answer, but a later request can still land on a front end without the rule and be refused with the platform's own 403 ("Ip Forbidden"). That happened on 2026-10-23 (run 39997538990): health 200, the public content list 403, the detail read 200, and the deploy failed after everything had been deployed and registered.scripts/smoke-deployed.mjsnow sends every request throughoriginFetch, which waits 5 seconds and tries again when, and only when, the answer is a 403 carrying the platform'sx-ms-forbidden-ipheader, for at most 90 seconds across the whole run. An app-level 403 (the admin guards, the CORS refusal) is never retried, because those checks expect it, and a firewall refusal can no longer make them pass for the wrong reason. A run that had to wait says so on one line. The script needs no redeploy to take effect: the workflow runs it from the repository. -
The weekly bump no longer lets kubectl run ahead of the Kubernetes schemas kubeconform checks against (#1323). The lab image pins kubectl (
KUBECTL_VERSION) and a copy of the Kubernetes JSON schemas (KUBERNETES_JSON_SCHEMA_*) that are meant to sit on the same Kubernetes line, but the weekly bump only ever edited kubectl, so the first new line would have left kubeconform validating against the old API with a green build. The schema pins are a commit and a tree hash that no publisher serves a checksum for, so a bot cannot move them with kubectl. The bump now takes kubectl's patch releases from the pinned line's own pointer (stable-1.37.txt) and, whenstable.txtnames a newer line, adds a "Kubernetes release line" note to the weekly issue instead of moving kubectl: the line is moved by hand, kubectl and the schemas in one pull request. A test fails whenKUBERNETES_JSON_SCHEMA_DIRandKUBECTL_VERSIONare on different lines, so a hand bump that moves one fails in CI. Patch releases stay green because the check is on the line, not the patch number. Six new cases inlab-pin-bumps.test.mjs; breaking the planner or the pins five ways turned them red. - A Docker upgrade no longer leaves Portainer's
localenvironment down until someone restarts the container (#1328). Portainer holds the Docker socket as a bind-mounted file, so when Docker restarts (any docker-ce upgrade does) a Portainer that was already running keeps the old, deleted socket: it still answers, but it can no longer see this host's Docker. The 2026-10-21 bootstrap (Docker 29.9.1 to 29.9.2, #1324) left it so, the 15-minute check failed three times in a row and raised the lab alert, andsudo docker restart portainerwas the cure. The agent's socket proxy had the same fault in #1135. Theportainerrole now installs thelabs_agentrole'shcw-docker-proxy-stalehelper (the same file, to the same path, so there is one copy to keep right) and asks it whether Portainer started before the daemon's current start. If it did, the container task restarts it onto the new socket and the run says so; if it did not, nothing is restarted. The weekly run proposes Docker upgrades, so without this it would have come back each time.portainer-stale-socket.test.shholds the wiring (the restart tied to the answer and not to every run, asked before the container task, skipped in check mode, one helper file for both roles) and runs the helper against stand-ins forsystemctlanddockerwith this role's own container name; putting each of eight breaks back in turn turned it red. CI runs it in theansible-lint (lab-host)job. It does not prove a live restart, which shows on the first bootstrap that upgrades Docker. A Portainer that has only just started may still read its environment as down for a moment, and the role runs the check straight after the restart, sohcw-portainer-checknow asks again every 2 seconds for up toportainer_check_environment_wait_seconds(30) while the environment reads down. A refused token, a missing environment or an odd answer still fails at once, so a real outage is reported about half a minute later at most. Three new checks inhcw-portainer-check.test.pycover reading up inside the wait, still reading down when it ends, and a missing environment not being waited for; breaking each of those three ways turned the tests red. - The weekly supply-chain check no longer skips the image scan and the
weekly issue in a week when a pin is behind (#1308). The check job's
first step runs
lab-pins-upstream.mjs --summary, which exits 1 when a pin is behind and 2 when a source could not be read, and records that code for the steps after it: the scan of the published lab images, and the issue that names what is due. Arun:step with noshell:isbash -e, which the step's ownset -uo pipefaildoes not turn off, sonode ...; code=$?ended the step at the first non-zero exit, before the code was recorded, and every later step was skipped. Two earlier runs (13 and 14 October) failed this way, and so did the manual run after #1320; the step can only fail like that when the checker exits non-zero, and the 20 October run, which passed, found every pin current. Since the weekly run now moves pins through pull requests that wait to be merged, a pin is routinely behind when the check runs. The step is nownode ... || code=$?. The test file that runs the workflow's steps now runs them withbash -e, as the runner does; the three new cases (exit 0, 1 and 2) fail on the old step, pass on the new one, and the old step put back is caught by the same mutation check as the others. The same run showed a second thing that would have kept the weekly output unusable: the host pull request it opened (#1324, which moves Coder to v2.38.1) failedlab-pin-bumps.test.mjs, because one test typedv2.38.0as the Coder pin it expected to find and so passed only while Coder stayed there. It now reads the pin from the real file, and passes with both the old pin and the new one. - The weekly lab supply-chain run now proposes tool and provider releases
for the lab images, and no longer fails the whole week when cosign moves
(#1308). Two gaps, both found by running the workflow's own steps
against the live publishers. First,
lab-supply-chain.ymlran only thehostandimage-basepin sets. Theimage-toolsset (Terraform, kubeconform, Helm, kubectl, the Azure CLI and the Terraform providers in the mirror) was built in #1213 with its tests and its pull-request text, which says the weekly run found the newer versions, but no loop in the workflow named it, so those releases arrived only when someone ran the bumper by hand (azurerm 5.10.0 and Terraform 1.16.6 were waiting this week). It now runs.image-baseandimage-toolsboth editlab-image/versions.envand the pull-request job applies one set's patch to a pristine checkout, so each of the two is planned from the pristine tree, held to its own paths, cut into its patch, and put back before the next one starts; the host set goes last because the Docker CLI fixture is recorded from its edits. Second, the cosign pin that #1240 added tolab-host/bootstrap.shis edited by the host planner, but the host set did not list that file, and the workflow fails the run when the checkout changes outside every set's paths. Cosign had not released since, so nothing showed; cosign 3.1.4 is out now, and the next Tuesday run would have failed with no pull request for any set. The host set listslab-host/bootstrap.sh, and its pull-request text says so. Because that is the script the owner runs as root on the lab host, the job that holds the App token now lets a patch change it only on its two cosign lines, and only to the shapes the bumper writes (digits and dots afterCOSIGN_VERSION=, exactly 64 lowercase hex characters afterCOSIGN_SHA256=, nothing after either, so a value cannot carry a command; CodeRabbit caught a first version that checked only how the line started): no other line, and no mode change, rename or binary hunk. An independent review of this change found the new per-set guard failed open ifgit statusitself failed; it now writes to files with plain commands, as the existing guard does. Tests keep this from coming back.lab-supply-chain-steps.test.mjstakes therun:blocks out of the workflow and runs them in a temporary git repository, with stand-ins for the bump scripts, then checks the patches, the guards and the refusals (breaking the workflow eight different ways turned it red each time). The text test requires each loop over sets to list the sets it should, and every set the bumper plans exceptimage-digests, whichpublish-lab-image.ymlopens, to appear. A third reads the files the host planners edit from their source and requires each in the host set's paths; it is a text match, so it can miss an edit written another way. The weekly issue, the--bumpdocumentation, the publish workflow's note and the tools set's pull-request text no longer describe the old two-set run, and that text no longer claims a checksum for the Azure CLI, whose pin holds a version only. The runbook, the lab host README and the lab image README now say the weekly run covers the tools. - The lab image's Terraform, Helm and azurerm pins move to their newest
releases, and the tool bump's tests stop depending on today's pins
(#1308). The weekly scan reports vulnerabilities in the Go code inside
the tools and providers baked into the lab images. Terraform 1.16.5 to
1.16.6 and Helm 4.3.0 to 4.3.1 are routine moves (Terraform's repository
still pins Go 1.26.8 at that tag, which the scan lists as vulnerable, so
they should not be expected to clear findings). The azurerm provider 5.9.0
to 5.10.0 matters more: its repository pins Go 1.26.9 at that tag, the
version the scan names as the fix for the standard-library findings in
5.9.0. Each pin moved with the checksum read from its publisher by
node scripts/lab-pins-upstream.mjs --bump image-tools, the set the weekly workflow does not run, so these releases would not have arrived on their own. Merging publishes the images; the next weekly scan, not this change, shows how many findings are gone. What the scan still lists for the alz, modtm and azapi providers, kubeconform, kubectl and the Azure CLI's bundled Python packages is already at the newest release of each, so it waits on those publishers. Two cases inlab-pin-bumps.test.mjsalso invented a "newer" release (azurerm 5.10.0, Terraform 1.17.0) on top of the realversions.env, so each stops being newer the day the pin reaches it, and azurerm 5.10.0 failed them on this very bump. They now work the newer version out from the pin, and pass with the old pins and the new ones. - An evidence row can name the one requirement it counts toward, and the
monthly audience rows do, so two audience months no longer read as
community work or hands-on material (#1313). Readiness matched a row to
a requirement by its source type alone, and the audience timer's rows are
manual, which Docker Captain'scommunityandlabsrequirements and Microsoft MVP'sreachalso accept. The timer's list namedreachas the requirement each row is filed under, but the stored row never carried it, and the evidence validator refuses a key it does not know. Now an evidence row has an optionalrequirementId:computeReadinesscounts a row that names one toward that requirement only (the requirement must still accept the row's source, so naming can narrow where a row counts and never widen it), and a row that names none counts as it always did. The timer storesrequirementId: 'reach'on every row and may update it on a re-run; a month already stored without it is read once more so the rows get it, and a row the owner removed is left alone. The checklist on an application offers the same rows readiness counts (evidenceCountsTowardinambassadorModel.jsmirrors the API's, with the same test cases). Docker Captain'slabsacceptsmanualagain, as revision 4 of its seed (the owner files hands-on material by hand); a stored seed that was never edited is brought up on the next read, and one the owner edited is left alone. Nothing to run: it takes effect with the next Functions and frontend deploys. -
The AVM versions bot regenerates the image's record of what each pinned version calls, so its pull request arrives green (#1306, #1309).
AVM_CHILD_CALLSinlab-image/versions.envis keyed by each builder module's version, and what a new release calls is only known once the image's vendor stage resolves it, so every bump the bot opened carried the record of the old versions: #1306 (avm-ptn-alz0.22.0, the connectivity module 0.17.7, the spoke module 0.22.3) arrived withscripts (operations)red on "records nothing for avm-ptn-alz@0.22.0 in AVM_CHILD_CALLS" and the image's Build and smoke red invendor-avm.sh, which the pull request body called by design.update-avm-versions.mjsnow does what that body told the reviewer to do: after writing the pins it runsdocker build --target vendor --build-arg AVM_PRINT_PINS=1 --progress=plain lab-image(with--no-cache-filter vendor, since a cached stage prints nothing), echoing the build log to stderr, splices theAVM_CHILD_MODULESandAVM_CHILD_CALLSblocks it prints over the two inversions.env(no other line changes, and splicing twice is the same text), rewritesLAB_IMAGE_CHILD_AVMinlabImage.jswhen the child list changed, and moves the exact pin on the module's block in the smoke payload (smoke/terraform-validate-payload/main.tf, whosemodule "alz"pins the builder's exact version on purpose and so broke on every bump of it). A build that fails, a print-out without the blocks, a line that is not the printed shape, or a record keyed by versions the file does not pin ends the run with exit code 1 and one sentence after the log, and no pull request opens. The workflow runsscripts/lab-image-avm-vendoring.test.mjson the result before the upload, stages the two new paths by name (five, asserted byavm-versions-workflow.test.mjs), and the body says the record was regenerated. The vendoring test's own "goes red" cases carried the spoke module's pin as a literal (0.22.2), which would have failed the first bump of that module with a perfect record in hand; they read the pin now, as #1300 did for the frontend suites. Rehearsed against #1306's pins on a workstation with Docker: the vendor stage printed 17 children and 19 call lines, the splice changed exactly the 18 lines keyed by the three moved versions beside the six pin lines, and the vendoring test went from six failures to nine passed.--children <log>splices from a saved print-out for a run without Docker. -
Study-guide refresher: an exam stored retired ahead of its day keeps its outline until the day. The refresher and the outlines test judged liveness by the stored status, so the first green updater run after #1289 deleted PL-400's outline eleven days before its last exam day (#1291, closed). Both now ask
deriveStatusfor today, as the badge does;eligibleCertslives inscripts/study-guide-eligibility.mjswith a unit test. -
Learn-catalogue updater: a refresh no longer deletes
statusReadDateandavailableDate(#1091's fields). The rebuild of every poster row carried an allowlist of fields that predated them, so each run erased them, PL-400 turnedretiredtwo weeks before its October 30 day and the data check refused the pull request (run 39218206063, after #1288). Both fields are carried now, with a unit test; a real run against Learn left only the sync date changed and the data tests green. -
Learn-catalogue updater: two Node set-ups, because the two trees it installs pin two lines. The Monday job failed on 2026-10-19 at
npm ciinfrontend/(EBADENGINE: the frontend has required Node 26.9 since the floors moved in #1161,functions/is pinned to the Node 24 line, both engine-strict). It now installsfunctions/under Node 24 and everything else under Node 26. The AVM updater's separate failure is #1287. -
Cloudflare token roll: a child process that exits before reading its input no longer throws
EPIPE(functions/src/lib/credentials/cloudflare-roll.js). The spawn helper installs anerrorhandler on stdin and writes withend(input), so callers get the exit code and stderr throughclose; the tests model stdin as an emitter and add theEPIPEcase. Found as an unlanded stash from #1247. -
The lab host pre-pulls every template's workspace image, not
hcw-lab's alone (#1268).lab_imagespulled one image into the Coder sandbox daemon, the digestlab-host/coder/templates/hcw-lab/main.tfpins. The guided-lab templates (amo-guided-lab,na-guided-lab) pin another, and name the localdefault_imagerather thanimage, so their first workspace after any bootstrap pulled about 2.75 GB while the visitor waited.files/lab-images.mjsnow reads the image of every template underlab-host/coder/templates(either local name) and plans each distinct digest onto the sandbox daemon; a template whose image it cannot read fails the plan rather than going unplanned.scripts/lab-images.test.mjsandroles/lab_images/tests/lab-images-role.test.shhold every template's pinned image to the sandbox plan, reading the pins without the helper. The templates' digests are unchanged; the host holds two images until the guided labs move tohcw-lab's. - Lab workspaces resolve names again, and the lab host proves it at every
run and every 15 minutes (#1267). From the host firewall's first run on
2026-10-17, every Coder workspace started its container and then never
connected. Coder showed "Agent is taking longer than expected to connect",
because the container could not resolve
coder.lab.hybridcloudworks.comto download its agent. The Terraform docker provider (kreuzwerker/docker4.6.0) creates each workspace on the sandbox daemon's default bridge and then moves it to the workspace's own network. A container born on the default bridge gets systemd-resolved's upstream servers, not the stub, and keeps them.dockerdthen asked them from the host's namespace ashcw-coder-docker, whichHCW-SANDBOX-OUTrefuses: at 23:20Z it had refused 576 packets since the 13:12Z boot, and its resolver rule had matched none. The firewall's README had said only a container on the default bridge, "which no template uses", would get those servers. - The fix: the sandbox daemon's
daemon.jsonnow setsdns: ["10.0.2.3"], slirp4netns's forwarder, for every container. Its lookups reach the host stub, the one resolver the firewall allows, whichever network a container was created on. Every template is covered, with no template edit. After each start,coder_sandboxreads rootlesskit's API and refuses a forwarder that is anything else. On the host, the provider's create sequence with that nameserver resolved, fetched the agent, and booted all ninehcw-lablabs. - The check that would have caught it: a new
workspace_reachrole. It runs frompost_tasksafterprivilege_checks, whatever the tags, so it follows every role and the handlers they notified, and a failure stops only the commit record. At every run, and every 15 minutes fromhcw-workspace-reach.timer, it creates one container the way the provider creates a workspace, as the sandbox user, on the sandbox daemon, from the newesthcw-labimage there. From inside, the container resolves Coder's name, fetches 100 bytes of the agent and runsgit ls-remoteon the lab repository. A failure fails the run, naming the step, what curl or git said, and where to look. From the timer, it reachesalert-lab-unit-failedthroughhcw-unit-failed@, and it is skipped while the sandbox user does not linger (Coder off). A plaindocker run --networkresolved on the broken host, so the check copies the provider's creation order, and its test holds that order. - The
host_firewallandcoder_sandboxREADMEs now describe the path as measured. The runbook gains "Workspace reach", which covers reading each failure and restarting a workspace that was stuck before the fix. - The lab host records the commit it converged from again.
site.yml'sRecord the commit this host converged fromruns only on a full run (ansible_run_tags == ['all']). On ansible-core 2.21,ansible_run_tagsis a tuple, which never equals a list, so no run has written/etc/hcw/applied-commit.jsonsince the upgrade. On 2026-10-17 it still named 2026-10-14's commit after two full runs, and the agent's heartbeat reported that commit. The condition now readsansible_run_tags | list == ['all']. Run on ansible-core 2.21.4, it is true on a full run and false with--tagsor--skip-tags, andlab-host-site-tags.test.mjsrefuses the old comparison. - Coder's first-account check waits for Coder to answer (#954). The first
bootstrap after #1246 recreated the Coder container with its new sign-in
settings. It then asked
/api/v2/users/firsttwo seconds after the container started, before Coder was serving. The published port reset the connection, and the run stopped there (2026-10-17).up --waitreturns once the container runs, not once Coder serves. The question is now asked until Coder answers 200 or 404, every 2 seconds for a full minute (31 attempts). A Coder that never answers still fails the run before its route is written. Run against a stand-in server that started six seconds late, it answered 200 on the fourth try. - Data-subject request runbook: supported commands, real estate names, and reproducible exercise record (#1237).
The runbook (
docs/runbooks/data-subject-requests.md) was corrected to eliminate fictional Azure CLI commands (az cosmosdb sql query/document delete) and non-existent resource names (cosmos-hcw-prod-cus,sthcwprodbackupscus). It now establishes: - Supported operational access: Web UI via the Admin Ambassador Hub with optimistic ETag
concurrency, and an operational CLI tool (
scripts/data-subject-search.mjs) querying Cosmos DB accountcosmos-site-prod-cusand databasehcwviaDefaultAzureCredential. - Thorough search: Recursive inspection of all text fields across
ambassadorrecords and full enumeration of referenced files and images, providing Azure Storage CLI commands to delete matched blobs fromstsiteprodcus01(containerspeakerevents). - Public submissions clarification: Confirms that public submissions in
content(functions/src/lib/submissions.js) are completely anonymous and store no submitter or author email addresses. - Backup retention verification: Replaces fictional backup accounts with the real
storage account
stsiteprodcus01, containercosmos-export, and verifies lifecycle ruleexpire-cosmos-export(35-day deletion). -
Verification exercise record: Replaces the unverified record with a verified, reproducible PASS using real commands and real estate assets.
-
Lab host: stale-bridge cleanup purges orphaned NAT rules for removed bridges (#1228). When rootless / userns-remap setups or dead Docker networks leave stale bridges,
hcw-docker-stale-bridges.pyremoves the dead network bridge viaip link delete. However, Docker's default iptables rules in thenattable (such as-A POSTROUTING -s <subnet> ! -o br-<id> -j MASQUERADEandDOCKERchain jumps) remained behind in netfilter, matching unintended host-egress traffic and masquerading container-to-container connections as host gateway IPs. The script now inspects the IPv4nattable (iptables -w -t nat -S) and removes all matchingPOSTROUTINGandDOCKERrules referencing dead bridge interfaces by exact specification viaiptables -w -t nat -D <chain> <rule_spec>, reportingdeleted_rulesalongsidedeletedbridges. ThedockerAnsible role triggers on either deleted bridges or deleted rules, andprivilege_checksenforces the invariant that no IPv4nattable rules reference nonexistentbr-*interfaces.
Changed¶
-
Python 3.15 for the lab image, the lab host and four CI jobs, on ansible-core 2.22.0 (draft until PyYAML ships a wheel for 3.15; follows the 3.15 floor in #1458). The lab image's base moves to
python:3.15.2-slim-trixieby its own index digest (the one the patch tag carries since its riscv64 build landed on 2026-11-02; the linux/amd64 image inside is unchanged),lab-host/bootstrap.shto uv 0.13.6 (the first uv that can install CPython 3.15.2) and CPython 3.15.2, and theansible-lint, harness, complexity and documentation jobs topython-version: '3.15'. ansible-core is 2.22.0 (released 2026-11-02; the branch carried 2.22.0rc2 until then), with its dependencies re-resolved for CPython 3.15, because 2.21 stops at start-up on 3.15, and theansible-lintjob installs the same 2.22.0. One publisher still blocks the image and the host: PyYAML 6.0.3, still its newest release, has no wheel for CPython 3.15, and since 2026-07-22 PyPI refuses a new file on a release older than 14 days, so only a new PyYAML release can clear it (yaml/pyyaml#951). -
Docs: the delivery monitor's Telegram messages are explained, and the Linkie key's permission label is corrected. Owner request 2026-10-24 ("update Pages and readme").
docs/runbooks/alerting-and-support.mdgains The delivery monitor speaks on Telegram: the four message titles (DELIVERY MONITOR RED,STILL RED,back to green,WATCH IS BLIND), what each means and what to do, the 8-hour threshold and the two limits;how-it-is-built.mdlinks to it. The two pages that list the Linkie key's permissions now say the key screen labels Automated posts as Posts (seen 2026-10-24). The README was read and needed no change: it describes only what a visitor can use today, and the Interviews page has no interview yet. The Pages site itself was already current: its build succeeded on the merge of #1339. - Azure Architecture MCP Appliance on Azure MCP Server 3.0: the architecture
analysis and design proposal is on the docs site (#1314).
docs/architecture/azure-architecture-mcp-appliance.mdis the analysis the owner asked for on 2026-10-21 before deciding whether to open a new App repository and a new AddOn repository for the appliance. It pins Azure MCP Server 3.0.0 (tagAzure.Mcp.Server-3.0.0, branchrelease/azure/3.x), verifies each capability the brief listed against the pinned source and Microsoft Learn, corrects four assumptions (no Resource Graph query tool, no Cost Management tool, local-only Terraform, aztfexport, conftest and azqr tools, elicitation-gated sensitive tools), recommends on-behalf-of authentication for labs and production, and lays out the evidence and ADR models, the detector and MCP tool catalogues, the promotion matrix, deployment, security, threat model, testing, roadmap, risks and probability estimates. The page is a proposal: nothing is provisioned and no repository was changed by it. - Ambassador hub: the seeded programmes carry the published rules (read 2026-10-20) (#1304). The Evidence tab counts against what each programme publishes, not against a first seed's guess.
- Docker Captain takes its rules from docker.com/community/captains and
the Advocu join form: open internationally at 18+, the three tracks
(Advisor on a Docker member's recommendation, Thought Leader at 5,000
monthly views, Community Contributor at five years), the "sail the extra
mile" exceptions, a one-year term renewed on continuous contribution with
no numeric content quota, rolling applications with interviews a month or
more out, and the perks as listed (discounts, not free seats or a
pull-rate exemption). Its requirements become a measured audience figure,
Docker content, community leadership and hands-on material, and the
application link is the join form. A stored row still holding the guess
updates itself on the next read, field by field
(
SUPERSEDED_SEED_VALUES, revision 2); a field the owner edited stays. - Docker-Sponsored Open Source, HashiCorp Ambassador and Coder
Champion join the seeded catalogue, from their pages. DSOS: public Hub
repositories under an OSI licence, updates within six months, no
pathway to commercialization (donations allowed), the grants listed, a
yearly renewal, and a paid subscription and DSOS mutually exclusive.
HashiCorp: the three pillars, the three contribution categories, the
Principles, specific examples from the preceding twelve months,
self-nomination, the award expiring each year, and the 1 December to
5 January window with its notification form. Coder: five community
chats, two people helped in
#help, the starred repository and public templates, year-long then audited quarterly, self-nominated through the programme page. - GitKraken Ambassador (revision 3) words its rule as the page's tiers
(Content Creator at 2+ pieces a quarter, Influencer, Community
Ambassador, Feature Tester, Event Host at 30+ developers), the
programme-wide asks (brand guidelines, the logo on the site and
profiles, the quarterly check-in form) and the perks. The quarterly
upkeep and the one counted requirement are unchanged; the logo and the
check-in form are recommended activities, because the model counts
evidence and neither is a count of evidence. A seed's superseded record
now keeps its whole history (
revisions, each step the values it replaced), and the backfill matches a stored row against every step above its own revision, so a row from the first seed that never saw revision 2 is corrected rather than stamped current with the guess kept (CodeRabbit on #1304). - Windows for the coming year: MIEE moves to 1 May to 31 July 2027 on
the 2026 pattern, its description and its "apply before" line rolled
with it so the seed does not contradict its own window, and AWS
Community Builders' note records the 2026 close (2026-01-21) and the
January 2027 cycle. Each moves through
SUPERSEDED_SEED_VALUES, so a stored value the owner never edited follows the seed, and one they edited stays. MCT Regional Lead is not moved: its October 2026 window is open until 2026-10-31, and a stored row would show it a year away while it is live; it rolls to October 2027 in November. -
CodeRabbit spends its one included review an hour on the head someone will read. The Advanced trial's ten reviews an hour became the open-source tier's one on 2026-10-20 (#1301 and #1304 sat on
Review rate limited; #1304 asked eleven times and was refused ten), and.coderabbit.yamlnow reviews no draft, pauses incremental reviews after two reviewed commits, skips Dependabot's pull requests (the owner approves those, #1299) and turns off the docstring pre-merge check, which has only ever reported zero functions. Pull requests fromhcw-site-app[bot]stay reviewed, because CodeRabbit's approval is their required review (#1293). A review on this repository is asked for by hand with@coderabbitai review. -
Foundry's first month is recorded on an estimate, and #849 closes on it.
docs/architecture/cost-analysis.mdgains Foundry — first measured month (October 2026, read 2026-10-20): USD 5.36 spent on theFoundry Modelsmeter inrg-ai-site-prod-cusby day 20 of 31, a straight-line USD 8.31 and Azure's own USD 10.60 forecast against the USD 75 budget, so an October estimate of USD 8–11. Owner decision 2026-10-20: with spend at a seventh of the budget, the invoice closing in November is not worth holding the issue open for; the closed figure lands in the monthly re-read (Measurement plan, item 1). The budgets table also takes the live namessite-monthly-budgetandsite-foundry-monthly-budgetin place of thehcw-names it carried; the live name followsvar.workload_name, which issite, andaz consumption budget liston 2026-10-20 returned only the twosite-names. alert-lab-ssh-loginis live and has fired; its plan declaration and StreamYard's are pruned (#953). The owner appliedrun-6uJYNGWLDxkdv5ZB, whose plantfc-plan-check.ymlread as the alert's create beside the permanent diff.verify-alert-state.ymlthen read the rule OK, andInvoke-AlertFireTest.ps1 -Test lab-ssh-loginopened an alert at 00:01:44Z on 2026-10-17, six minutes after its line was written. The fire record carries the row. The alert'sDECLAREDentry and #1101'sSTREAMYARD_API_KEYreference, live as declared, are deleted. The probe flag's entry (#701) stays: the live setting still readsfalse. A test holds all three.- Credential register reads live dates the estate already knows (#1066).
The credential register (
functions/src/lib/credentials/) and Credentials tab view (frontend/src/components/admin/integrations/) now automatically resolve live dates from estate sources: Key Vault secrets resolvecreatedAtfrom metadata whenlastWriteAthas not yet been set; lab host certificates (/etc/hcw/labs-agent.pemand/etc/vault.d/tls/vault.crt) resolvenotBeforeandnotAfterfromadmin_config/lab_certificates(reported viavps-agent); and the Static Web App deployment token resolves its reset date fromadmin_config/swa_reset. Manual recording remains supported for owner overrides. alert-kv-config-write's plan declarations are pruned, and the loop is proven gone (#1216). The owner applied #1217 inrun-KZEnEksD78i8wtKr, whose plan the check read as the four declared provider additions, so the state had held[].verify-alert-state.ymlthen read the rule OK. The three alerts the cleanup itself had caused were closed at 15:10Z, and no new one opened in the ten minutes after. Five entries are deleted, and a test keeps them from coming back.- The plan check's applied declarations are pruned (#1207, #964,
#970). Ten
DECLAREDentries inscripts/assert-expected-plan.mjshad met their own removal condition: - The eight for #1207's four stateful rules. The owner applied them in
run-qrsPoBGgTrAZyqwaon 2026-10-16, andverify-alert-state.ymlthen read all four OK withautoMitigatetrue. alert-timer-overdue's query, whose Resource Health now reads Available.alert-export-window-latency's create, which the verifier reads OK.
They are deleted, along with the pinned timer rows only the
timer_overdue entry used. The history comment records each one, and a
test keeps them from coming back.
- Both backup-missing rules are on the Fire record: fifteen of fifteen
(PLAT-4, #964). Neither needed a staged test, because each fired on
the real condition when it was created on 2026-10-14. That was before
the host had uploaded anything. alert-lab-backup-missing-coder first
opened at 06:23:14Z, with its actions run, and stopped after the first
Coder dump landed at 03:37:18Z on 2026-10-15.
alert-lab-backup-missing-vault first opened at 06:30:23Z and stopped
after the first Vault snapshot at 14:35:10Z. Read from Alerts Management
on 2026-10-16, so the two-night backup stop the rows used to call for
was not made. alert-export-window-latency keeps its design test, since
a degraded export window cannot be staged.
- Every log rule whose condition can clear now resolves itself (ADR 0022
decision 6, amended). Four rules muted their repeats rather than
resolving: alert-logs-capacity, alert-lab-disk and both
alert-lab-backup-missing rules. A mute still opens an alert at every
evaluation over the threshold and closes none, and on 2026-10-16 there
were 43 such alerts open for conditions long gone. All four are now
stateful: one alert per episode, resolved two clear evaluations later.
The capacity rule's comment said its condition could not clear before the
08:00 UTC reset, and that the reset left nothing to auto-resolve. That
was wrong: the reset clears it. Stateless by reason, and so recorded:
- the three daily rules, which Azure will not make stateful;
- alert-lab-unit-failed, whose condition is a log line, not a state;
- the Activity Log alerts, stateless by platform.
The 205 alert instances open in both subscriptions were closed the same
day, each with its reason. 137 had already resolved and 43 were stateless
repeats of cleared conditions. The other 25 were security events, each
traced to a fire test, a Terraform apply or the owner's own role change.
Live after the owner's hcw-azure apply.
- alert-timer-overdue is seen to fire: thirteen of fifteen alert rules
are now on the Fire record (PLAT-4, #964). After the owner's apply put
#1195's corrected query live (20:42Z on 2026-10-15),
Invoke-AlertFireTest.ps1 -Test timer-overdue disabled healthPulse at
20:48Z and the alert opened at 21:52:30Z naming it; the setting was put
back as recorded. The runbook's Fire record row is dated. The two
backup-missing rules remain, by the owner's call.
- The Live Pages and Editor list truncation tests stand a bare element in
for each row, so a loaded full run no longer times them out (#1194)
(AP-F6, #930). Both proved the 500-item banner by rendering 500 full rows
in jsdom: 0.8 to 1.1 s alone and 2.5 to 3.8 s in a full npx vitest run,
so other suites on the same machine pushed them past the 5 s default. The
banner reads only how many records came back, so LivePageRow and the
Editor list's LiveItemRow move unchanged into
pages/admin/livePages/LivePageRow.jsx and
pages/admin/editor-list/LiveItemRow.jsx, where the tests at the limit
mock them; every other test in the two files renders the real row. The
read, the count check and the banner are still the page's own: a test
fails if the banner goes or its >= becomes >, and a new test at 499
items in each file fails if it fires one short. They now take 65 to 117
ms in four full runs, two of them with the functions suite running
alongside. No timeout was raised.
- No raw error text reaches a response body, and a repository-wide guard
keeps it that way: follow-up to #1198 for #945 (AP-B10). #1198 converted
fifteen modules and checked those fifteen files for one spelling,
(message|error): (error|err)?.message; error: error.message, any other
variable name, any other file and text passed through a helper all went
unseen: run against c9536e03 (as against b23a7c73), the new guard finds
197 sites, 37 of them raw text inside a response body (Cosmos, provider
or socket text), where the old one passes.
functions/src/lib/http/error-response.js is now the one door: an
AuthoredError (and its subclasses: the CMS, settings,
taxonomy, MCP, ElevenLabs, AI-router, GitHub-import, gallery-fetch, job
payload-bound and rate-limit refusals) carries a sentence this code wrote,
authoredMessage and failureFields show it and replace anything else
with the route's safe sentence, and every failure answer carries a
correlationId whose log line holds only the sentence, the error's
content-free facts (class name, code, HTTP status, Cosmos substatus, the
cause's code), up to three places in this repository's code where it was
thrown (at=src/lib/jobs.js:657) and that id. The helper used to log the
whole error; the message is not logged now, and of the stack only those
locations. A caller's x-correlation-id is used only when it has the id
shape, and the v4 worker's traceParent is read. The alerting runbook
finds a reference's line in Log Analytics. Provider values that used to be
relayed are now their status and machine code, each held to the door's
code shape (codeShaped) before it goes into a sentence: Resend's error
name, the OAuth error code and token_type, ElevenLabs's detail.code,
the JSON-RPC code, a preview's content type, and the MCP sign-in
metadata's addresses (scheme, host and a URL-character path only). A
failed connection probe answers { ok, status, error } and no upstream
body; the provider's sentence on a rejected key stays on the super_admin
API-keys page (#463). no-raw-error-leaks.test.js replaces the
fifteen-file regex with a parse of every module under functions/src:
.message or .stack off anything, a caught error stringified
(String(e), a template literal, +), a settled promise's reason,
aliases and destructuring, scope-resolved by catch and .catch()
parameter; a site is any object field, return, stored field or helper
argument it lands in, a site inside a response builder's body (resolved
by import, or an injected one called with a literal status) is marked,
and an aliased AuthoredError subclass built from someone else's text is
a site. A helper whose return holds a stored or log-only site carries its
text to its callers, and that text is a site where it reaches a response
body: the Podbean hub's reads and the Labs snapshot's lab checks were both
that, and step 2 fixed them. Stored failures follow the same rule at the
moment they are written: storedFailure and recordedFailure keep an
authored sentence, or store the site's own sentence with the error's code
and the reference of the content-free log line (The job failed
(ECONNRESET; reference <id>).), still one string where a string was.
That covers job.error, the Listen & Learn and podcast failure fields,
the Podbean host record and the hub's reads of it, the cover, caption,
inspection and Publer sync errors, the health pulse, the lab checks and
the drift read, the Plaud token, the pricing and RSS job results, the
Telegram activity log and the scheduled-publish records. The job workers'
refusals and the study-guide, script, transcript, video, cover, grounding
and Podbean failures are now AuthoredErrors whose sentences name a
status and the provider's code, never its words, so those pages still say
what went wrong. Documents already stored keep the text they hold: nothing
is migrated. The 149 remaining sites (none of them raw text in a response
body) are listed in 146 entries, each with a class and a reason (100
authored through the door, 42 that never reach a response, three message
fields that are not an error's, and one stored diagnostic kept by
decision: a provider's sentence on a rejected key, which #463 made the
diagnosis and only the super_admin API-keys page reads), and the list
fails on a new site, a stale entry and a wrong class. getJob now holds a
job whose type is no longer registered at the enqueue floor (editor)
instead of the viewer gate, and answers its 500 with a correlation id; the
certification routes are pinned to refuse _createdAt, _updatedAt,
_etag and _ts; a Forge Studio entry whose job write failed is said to
be back in the queue only when the revert confirms it; and the admin
client appends the correlationId to a failed call's message
(… (reference <id>)) and keeps it on the thrown error.
-
Public reads return an allowlisted set of fields. The article detail (
GET public/content/{slugOrId}), the signed preview, the Listen & Learn certification read, the podcast list and the news feed each answer with a named list of fields, projected in the Cosmos query where the read is a query and filtered in code on every path, instead of a stored document with a denylist removed. A field a writer adds to a document is no longer public until it is listed. The lists are derived from what the public pages read (PUBLIC_CONTENT_DETAIL_FIELDS,PUBLIC_PREVIEW_FIELDS,PUBLIC_LISTEN_AND_LEARN_EPISODE_FIELDS,PUBLIC_LISTEN_AND_LEARN_SET_FIELDS,PUBLIC_PODCAST_FIELDS,PUBLIC_FEED_CACHE_FIELDSinfunctions/src/lib/public-reads.jsandpublic-preview.js), andpublic-reads.allowlist.test.jspins each one against the frontend components that read it and fails when a response carries a key its list does not name. The article list keeps its existing projection, less the framework builder's three generation prompts (no public page renders them, anduseFrameworkDatano longer maps them), and now also filters in code; the snapshot read keeps its publish-time sanitizers. -
Lab host: the nightly reboot asks at the hour, and reboots only an idle host (LAB-13, #959). unattended-upgrades evaluated its logged-in check when the upgrade ran, about twenty-two hours before the 04:30 it then scheduled
shutdown -rfor with no further question, and nothing ever asked whether a visitor's lab job container was running; a job killed at the 04:30 reboot is the issue's evidence. The hardening role's override now setsAutomatic-Rebootto false (the two settings read only while it is on are gone), and a newhcw-reboot-when-idle.timer(*-*-* 04:30:00fromhardening_unattended_reboot_time,Persistent=false, no jitter) runs/usr/local/sbin/hcw-reboot-when-idle, a root oneshot with the file system read-only to it, home hidden and the failure notifier inOnFailure=. It reboots only while/var/run/reboot-requiredexists, logind lists no login session (loginctl, classuseroruser-and a suffix; themanager,manager-earlyandbackgroundsessions of lingering service managers and cron do not count), the host daemon has no container with the agent's job label up (hardening_reboot_job_label, held toJOB_LABELinvps-agent/lib/docker-runner.jsbyscripts/lab-job-limits.test.mjsand by the role's test; up is created, restarting, running or paused, so a job whose container exists and has not started counts), and the Coder sandbox daemon has no workspace container up (coder.workspace_idon/run/hcw-coder-docker/docker.sock, asked as root when the socket is there: a person working in a workspace has no logind session and the host daemon does not see it). It asks twice (CodeRabbit on #1191): when the first look finds the host idle it writes a maintenance hold,/run/hcw-maintenance/reboot-hold(hardening_reboot_hold_path, root's, 0644, in the service'sRuntimeDirectory=), holding the epoch second it lapses (300 s on), waits for the labs agent to acknowledge it, and looks again. The agent reads the hold on every poll tick and claims nothing while it holds (vps-agent/lib/maintenance-hold.js; the claim loop moved fromindex.jstolib/poll.jsso it has tests;LABS_AGENT_MAINTENANCE_HOLD_PATHfrom the newlabs_agent_maintenance_hold_path, the same path, held together byscripts/lab-job-limits.test.mjs), caps any hold, unreadable ones included, at ten minutes from its write, and reads the time and the expiry from one descriptor, so a hold renamed over between two calls cannot pair one file's cap with the next file's expiry (CodeQL js/file-system-race). On a tick with no claim in flight and no job running it acknowledges the hold, writing its expiry exactly as read to/run/hcw-labs-agent/maintenance-ack(labs_agent_maintenance_ack_path,hardening_reboot_ack_path,LABS_AGENT_MAINTENANCE_ACK_PATH, one path), through a temporary file and a rename, in the agent unit's newRuntimeDirectory=hcw-labs-agent, which is not preserved, so a stopped agent leaves none behind; it withdraws it when a claim or a job appears, when the hold lifts, and when it stops. The script waits up to 45 s (hardening_reboot_hold_settle_seconds, at least a 20 s claim and one 15 s poll tick) for an acknowledgement naming its own hold, asking first whether systemd calls the agent inactive or failed, when no wait is needed, and defers the night without one (CodeRabbit's second round on #1191: a fixed wait could not see a claim whose container was slow to appear). An agent that cannot acknowledge, an older or a stuck one, so costs a night's reboot and never a job, and the firstbootstrap.sh, which enables the timer before it deploys the new agent, defers rather than risks one. The second look still asks logind and both daemons; the hold is removed on every way out but the reboot, where it stays until the reboot clears/run. One syslog line per outcome under the taghcw-reboot-when-idle: the reboot and the deferrals at warning, which the Arc rule ships, and a deferral of a reboot pending 7 days or more (hardening_reboot_overdue_days) at err, with what to do, never a forced reboot; the days count from the first night a run saw the marker, kept beside the hold in/run/hcw-maintenance/reboot-pending-since(cleared with the marker by a reboot), because every upgrade that wants a reboot rewrites the marker's own time, and the held report ages its pending line from the same record; nothing pending at notice; anapt-config, aloginctlor a docker daemon that cannot answer, docker missing included, or a hold or record that cannot be written, is an err line and a failed run, so the notifier pages rather than the host guessing. A stop exits 143, which the unit counts as success (SuccessExitStatus=143), so neither an owner'ssystemctl stopnor the reboot's own stop job pages. It does not askusers: on the host that is rust-coreutils, which reads utmp, and systemd no longer writes utmp, sousersandwhoprinted nothing with an SSH session open (measured 2026-10-15). unattended-upgrade's WithUsers check readuserstoo, so #1172's "no reboot with a user logged in" never deferred anything there. At most one reboot authority at a time: the role first cancels ashutdown -rthat unattended-upgrades scheduled before the run (one its log names; any other scheduled reboot stops the run with what to do, because a rootshutdown -rfor the same minute is the same record; the cancel's read-back is keyed on the cancel having run, since check mode registersrc0 for a command it skips), then installs, enables and reads the timer back active, writes the override that turnsAutomatic-Rebootoff, and reads apt's effectiveAutomatic-Rebootback, stopping if a later file turns it on again. The script stands down with a warning line while apt hasAutomatic-Rebooton, so a partial run or a rollback never has both rebooting; a run that cancels unattended-upgrades' reboot and then stops before the override leaves one night with neither, until unattended-upgrades schedules again orbootstrap.shis re-run, and the runbook has the line that withdraws the timer and the three limits kept on purpose. The daily held report names the timer and its hour in its pending-reboot line, the role refuses a reboot hour that is notHH:MMand a hold outside its bounds, andtests/hcw-reboot-when-idle.test.shruns the rendered script against stubapt-config,loginctl(the host's measured session listing among its fixtures),docker(both daemons, filtering on container state as docker does),sleep(the wait, where the agent acknowledges on a chosen second, with the wrong hold, or never, and where a job, a workspace or a login appears),systemctl(the agent's state) andloggerin theansible-lint (lab-host)job;lib/poll.test.jsandlib/maintenance-hold.test.jshold the agent's side (an acknowledgement only under a hold with nothing in flight or running, written atomically, withdrawn when busy or lifted), andscripts/lab-host-docker-proxy.test.mjsthe sandbox socket. The backup and Coder timer comments and the Vault pages that named "the unattended-upgrades reboot at 04:30" now name this timer, and the two Vault passages that said a reboot needs three unseal keys say that holds under Shamir keys only: this host auto-unseals, so its idle reboot comes back unsealed.alert-lab-unit-failedin the alerting runbook lists the idle reboot and the held report among the units that page. The timers' schedules are unchanged. On the host after the nextbootstrap.sh; the runbook's two checks after it areloginctl list-sessionslisting the login as classuserandshutdown --showprintingNo scheduled shutdown.. Ask 2 of #959; asks 1 and 3 are their own changes. - A late podcast publish hook clears only its own job's reservation, under
the version it read (#942, follow-up to #1198). #1198 made the sweeper
hand a poisoned or reaped job's
onCompletethe whole document, which made the podcast publish hook live on those paths; it already ran onrunJob's timeout. Running late, it read the transcript and wrote unconditionally, so it could clear a newer Publish'shost.podbean.pendingmarker or erase an episode a racing worker had just stored, and a second job could then put the same episode on the feed.recordHostJobFailurenow takes the job's id, writes only while the marker is pending and names that job, and writes withifMatchon the_etagit read; a 412 is read again at most once, and a marker that has moved on is the answer. Reading whole job documents is cheap only while their payloads are bounded, and that bound now holds at every door:newJobDocrefuses a payload over its type'smaxPayloadBytes(JOB_PAYLOAD_TOO_LARGE, 413), the Cosmos export scheduler and the Telegram /approve command, which wrote object literals, now build their documents with it, a Forge Studio entry whose document cannot be built is put back to queued, andjobs.payload-bound.test.jsreads everyupsertDoc/createDocto the jobs container (byJOBS_CONTAINER, a'jobs'literal or a local name for it) and fails one whose documentnewJobDocdid not build. A payload over the bound is the caller's 413, not a 500: Listen & Learn's routes answerJOB_PAYLOAD_TOO_LARGEwith a fixed sentence, and the recording upload measures its job payload before it stores the audio, since a title within 200 characters can JSON-escape past the 1024-byte bound. Newjobs.signal.test.jskeeps #1198's threading in place: it reads every module undersrc/that registers a job type (aliased and namespace imports included), pins the eight modules and the sixteen types, and fails a worker that never hands its signal to a call. Only an argument that carries the signal counts (the signal under any name,ctx.signal, or the whole context, so a thin wrapper passes); one that destructures it unused, only checks it first, names it in a string, or passesBoolean(signal)orsignal.aborteddoes not.noopwas the one worker that ignored it; its delay now ends with the job. And #942's own acceptance, that a timed-out job's side effects stop, now holds for writes: under #1198 a worker checks its signal at points it chooses, but its Cosmos and Blob writes went through shared clients that never saw it, so one that started after the deadline still landed (a timed-out Podbean publish rewrote the transcript'shost.podbeanafter the job was recordedtimeout). Newlib/job-context.jsruns the worker in the job's context, andcosmos-client.jsandblob-storage.jsrefuse a write that starts there once the signal has fired, and check again before a read-modify-write's replace and its retry. A write already dispatched when the signal fires runs to completion, SDK retries included, and the job's signal is never handed to the SDK: cancelling it on the client would not recall it from the service, so the write could land while the job saw it fail, and the records that follow it would never be written. Each refusal is a fresh error with the job's reason as itscause. Routes, timers and the hooks run outside the context, and so does work that other callers join: the MCP OAuth token refresh, a process-wide single-flight, is started withoutsideJob. A write that records something already done is kept past the deadline: usage rows (ai_usage), rotated MCP tokens (mcp_servers), and the version snapshot and audit row that follow a content write that has landed (content_versions,admin_audit_logs,audits), so a forge save cut off by the deadline still leaves its version and its audit row.runJobdisarms the deadline as soon as the worker settles, so a worker that finished just inside it does not see its signal fire during the terminal write, and the publish hook now warns, without ids, when every attempt met a 412 andpendingwas left set. - Admin routes are the menu labels' slugs and page titles are the
registry's labels, the single-draft RPC file says what it registers,
deleting a live page archives it with ten seconds of Undo and the Archive
board carries Restore and Delete permanently, and Ctrl-K opens a command
palette over every admin page and the content search (AP-F13, #937).
frontend/src/config/adminNav.jsis the one place a page's label, route and title come from: eight items routed to a slug that was not their label ("Publish" at/admin/published, "Review Queue" at/admin/queue, "Newsletter Hub" at/admin/mailing-list, "New Content" at/admin/submit, "Social Hub" at/admin/social, "Linkie Hub" at/admin/linkie, "Speaking" at/admin/speaking-events, "Platform Settings" at/admin/platform), andadminNav.test.jsnow holds every row to/admin/<slug of its label>. The old paths live on infrontend/src/config/adminRedirects.jsx, which also took over the five redirectsApp.jsxcarried inline (#442, #569, #570) and, unlike them, carries the query, the hash and a nested segment through (/admin/queue/<id>?source=contentstill opens the review board);adminRedirects.test.jsxwalks every entry and readsApp.jsxfor the route each lands on. Every link infrontend/src, the two e2e specs and the API's own admin links (functions/src/lib/decision-center/links.js,calendar/collectors.js,cms/drafts-handlers.js,timers/newsletter-autobuild.js, the ElevenLabs voice-picker URL) moved with them, sinceitemLinks.test.jsholds the two sides equal.components/admin/shared/PageHeader.jsx(andServicePageHeader.jsx, the Certifications page's) now render the registry label as the h1 and the document title; seven pages had written their own ("Content Queue", "Health Hub", "Integrations Hub", "Platform Settings Hub", "Speaking Events Hub", "Certifications Hub", "Ambassador Hub"), andPageHeader.test.jsxrenders the header at every registry route and reads every admin page's source for a literaltitle=.functions/src/functions/draft-http.js, one letter fromdrafts-http.jsand nothing to do with the Drafts stage, isgenerate-article-draft-http.js; the function name and route are unchanged. On Live Pages, Delete Live Page — one confirm, thensoftDeleteLivePage, which stamped a 24-hour soft delete nothing in the API ever swept — is Archive:published → archivedthroughtransitionContentStatus, which writes the audit row, and a toast with Undo for ten seconds (pages/admin/livePages/useLivePageArchive.jsx) that runs the newarchived → publishededge back.lib/cms/content-status.jsgained that edge (publisher-only, like every edge intopublished; the port's "no resurrect-to-live" pin is reversed with the reason), and its archived and rejected appliers now also write the legacyStatusword, becausepublic-reads.jsadmits a document onStatus: 'Live'as well as the flag —public-reads.test.jsapplies the archive patch to a document that says live every way it can and checks nothing serves it. Leaving the archive clearsarchivedAt; every non-rejected transition clearssoftDeleteExpiresAtbesidesoftDeletedAt. The Editor's Archive board (pages/admin/editor-list/ArchiveBoard.jsx) carries Restore to live and Delete permanently (confirm, thendeleteContentItem, the publisher RPC the Review Queue's delete uses;cmsDeleteContentis an editor-level hard delete and was left alone). MCP server removal keeps its confirm: the OAuth token lives only on the deleted document and is write-only, so nothing could restore it.components/admin/CommandPalette.jsx, mounted once inAdminLayout.jsx, opens on Ctrl-K, Cmd-K or its button as a Radix dialog — labelled, focus-trapped, Escape closes — listing the registry's pages filtered by what is typed and, from two characters, the content the list route'ssearchmatches (#1178;lib/api.jslistContentItems), asked once per pause in typing and answered in order; Enter opens a page at its route or a content item in its editor. Every edge out of a page the public site serves is a publisher's:lib/cms/content-update.jsgates onpublishedorisPublicDocument, so a liveeditingpage or a legacyStatus: 'Live'one is covered, and a caller that names no role is refused rather than waved through (the Telegram bot's/rejectnamessuper_admin, its caller being the owner's chat). Archive and reject clearscheduledPublishDate, as the flows Archive replaced did, sotimers/publishing-watchdog.jsdoes not count an archived page overdue. The pre-render manifest'sPUBLISHED_PREDICATE(lib/public-content-manifest.js) is now the public API's ownSQL_PUBLIC_CLAUSEandSQL_NOT_SOFT_DELETEDwith archived, rejected and drafting excluded; its old three words admitted documents the API answers 404 for.test/cosmos-predicate.jsevaluates it against documents. Live Pages offers Archive only on a published or rejected page, the statuses with an edge toarchived, and says why on any other; the palette asks the content search only for 2 to 100 trimmed characters, the list route's bounds (#1184), and says so past 100.transitionContentStatuswrites under the_etagof the read its gate and edge were decided from, and answers a lost race with a 409 ("The page changed since it was read; reload and try again.") and no audit row, so an editor's reject read before a publisher set a page live cannot land after it. Archiving a page also archives every linked legacyblogsdocument the site serves (publishedBlogId,sourceContentId,publishedContentId), stampedarchivedWithContentId, and Undo or Restore to live brings back exactly those;content-update.test.jschecks each withisPublicDocument.deleteContentItemdeletes under the read's ETag (deleteDocIfMatch), refuses with a 409 a body'sexpectedStatusthe document has left (the Archive board sendsarchived; the Review Queue and the framework review send none and keep their rules), and writes its owncontent_deletedrow toaudits, so the framework review's client audit row for the same delete is gone. The toast's Undo is sent once and disabled while in flight, and a restore on the Archive board re-reads the Editor's live list. The linked blogs are chosen from reads taken after the page's own write, each written under its read's_etag, and settled against the page as it then reads rather than the edge asked for: an archived page takes down every linked blog the site serves, a published one brings back the blogs it archived. Once a round's writes land the page is read again, and if it or a blog moved the round runs again (three at most), so a restore and an archive that cross leave the blogs agreeing with the page's final status; three interleavings are pinned incontent-update.test.js. A blog deleted in between, or still contested after the third round, is named in the audit row (linkedBlogIdsSkipped,linkedBlogsSettled: false) and the transition still answers 200.patchDoc's read-modify-write path (lib/cosmos-client.js) answers a document gone before its read withcode: 404, as the patch path already did.deleteContentItem's two failure lines are a fixed sentence and the error's code, with no document id. Rebased onto the role-cut menu (AP-F4, #928): Live Pages' Archive and the Archive board's Restore to live and Delete permanently sit behind the publisherRoleGate, and the command palette lists only the pagesvisibleNavForgives the signed-in role and asks the content search only for a role the Editor is shown to. - alert-timer-overdue evaluates again, a KQL test refuses what silenced it,
the weekly check reads every log rule's Resource Health, and the fire test
matches the timer dimension (PLAT-4, #964). The rule never evaluated from
its create on 2026-10-10: its query took each timer's last due time from
bin_at(Now - Grace, period, anchor), Kusto requiresbin_at()'s bin size and fixed point to be query constants, andperiodandanchorare columns of the rule's datatable. Every evaluation failed withSEM0040 bin_at(): failed to cast argument 2 to scalar constant, the rule's Resource Health readUnavailable / Semantic errorfrom 2026-10-10 22:55Z, and a fire test on 2026-10-15 stoppedhealthPulsefor 95 minutes with no alert. Azure accepted the rule at create, and nothing in CI ran the KQL. The line is nowanchor + period * tolong((Now - Grace - anchor) / period), checked againstappi-site-prod-cus-01for every timer due in the last day.scripts/assert-expected-plan.mjsdeclares the in-place update of the rule'scriteria[0].query, itsbeforeread back from the live rule character for character, and a new test holdsoverdueQuery()to the heredoc ininfra/.scripts/kql-alert-columns.test.mjsfails any rule that passes a per-row value where Kusto needs a constant:bin_at()'s bin size and fixed point, measured against the component (alet,now()ortoscalar()is accepted;bin()andfloor()take a column and are not checked). It failed ontimer_overduebefore the fix.scripts/verify-alert-state.mjsreads each live log search rule's Resource Health and failsUnavailableorDegradedasUNHEALTHY, naming Azure's title, and a read it could not make asHEALTH UNREADABLE, neverOK.Unknownpasses, as does a reason-lessUnavailableon a rule evaluated less often than every 15 minutes, for which Microsoft reports no health. The report gains a Health column. HCW Alert Rule Reader gains the twoMicrosoft.ResourceHealth/availabilityStatusesreads. The owner writes the role and registersMicrosoft.ResourceHealthinsub-plat-mgmt-prod-cus, unregistered there on 2026-10-15 (docs/runbooks/alerting-and-support.md, "Resource Health"); until both are done the run fails, naming each step. A refusal is never reported as an unregistered provider: that needs ARM'sMissingSubscriptionRegistrationcode or the measured 409AuthError. The runbook's rollout record reads the role's actions, both its assignments and the provider in both subscriptions, works in Windows PowerShell 5.1 as well as 7, and restores the previous action set from0bd89190(#1074).Invoke-AlertFireTest.ps1searched the whole serialized alert context forhealthPulse, and everycondition.allOf[]entry carriessearchQuery, which names all 26 timers, so any timer's alert would have passed as healthPulse's. It now matches the values ofcondition.allOf[].dimensions[], read at the live nesting (context.context) with a fallback one level up, and the Pester fixture takes the live shape, with a case wheresearchQuerynames every timer and the dimension another. The Fire record stays "not yet" until the test is re-run after the apply. - Lab host: ansible-core installed hash-locked from the lab image's own
pins, and the weekly pin bump moves it (LAB-13, #959). The
low-hanging-fruit entry below (#1178) says
lab-host/bootstrap.shhash-locked theansible-corewheel. It did not: the script printed anANSIBLE_CORE_SHA256it carried as a copy and installed withuv tool install "ansible-core==2.21.4", which takes no hashes, so neither that wheel nor the eight it depends on was checked. The script now keeps no ansible-core pin. After it checks out the commit it will run, it readsANSIBLE_CORE_VERSION,ANSIBLE_CORE_SHA256andANSIBLE_CORE_DEPSfrom that commit'slab-image/versions.envas data, never sourcing the file, so an edit to it cannot run anything as root, and refuses any requirement line that is not one pinned, hashed wheel, so nothing in it reaches uv as an option. It writes the requirements file with the lab image fetch stage's own twoprintflines, and installs it withuv pip install --require-hashes --only-binary :all:, thenuv pip checkand the fetch stage's count check, into a new venv under/opt/uv/ansible-core-generations, a directory of its own for every build, built beside the one in use. Only when that passes, and itsansible-playbook --versionreports the pinned release on the pinned Python, does the link/opt/uv/ansible-coremove to it, so a failed download, hash or check leaves the host on the environment it was using, even when that is a build of the same pins that has stopped passing; a generation is removed only after the link has moved off it, and one of other pins is kept until the next move.ansible,ansible-playbook,ansible-galaxyandansible-vaultare linked into/usr/local/binthrough that link, andhcw-vault-setruns its Python through it. The old uv tool under/opt/uv/toolsis removed after the play of the first run that has installed that helper, so a check run, a run narrowed with--tagsor a failed play never leaves the helper on a removed interpreter; a rollback withHCW_REPO_REFto abootstrap.shfrom before this change removes theansible-playbooklink before handing over, so that script reinstalls its uv tool. The install commands, run in an Ubuntu 26.04 container with uv 0.12.19 when first written, installed ansible-core 2.21.4 and its eight dependencies on CPython 3.14.7, refused a wrong hash and an unlisted dependency, and installed nothing on a re-run;hcw-vault-set's 81 checks passed with/opt/uv/ansible-corea link to a venv.scripts/lab-host-bootstrap-ansible-core.test.mjsruns the switch, the retirement and the handover against a stand-in uv, and holds the script toversions.envand to the Dockerfile'sprintflines. The weeklychore/lab-pins-image-basepull request now also moves ansible-core to the newest release in its line that has a wheel PyPI has not yanked, with the wheel's SHA256 from PyPI's JSON API and everyANSIBLE_CORE_DEPShash read back from PyPI, and moves a dependency only when the new release pins it exactly; a new line, a changed requirement or Requires-Python, or a hash PyPI does not serve is a note in the weekly issue instead (a dry run on 2026-10-15 proposes 2.21.4 to 2.21.5). On the host after the nextbootstrap.sh. - A Health view inside 30 seconds of the last one is served from the
cache instead of running the eleven-query fan-out again, and a Platform
settings save made from a page that is out of date is refused and reloaded
instead of overwriting the newer save (#946, the two acceptance items
the merge of #1181 left open). The ops-health snapshot cache lived inside
createOpsHealthHandlers, andfunctions/src/functions/ops-health-http.jsbuilds a handler set per request, so every request found it empty; it is now held byfunctions/src/lib/ops-health.jsfor the worker's life (resetOpsHealthCache()for tests), keyed on the store and environment the snapshot is built from and not on the caller, since every admitted role gets the same snapshot and the guard still runs first. The HTTP file passes one module-level store. A build serves every view while it runs, however long Cosmos takes, and for 30 seconds after it settles; a failed build is dropped rather than served; and an alert update invalidates it as soon as the alert is patched, before the audit writes that can fail. The Health page's first read takes the cached snapshot, but its refresh after an action (Reviewer Digest, RSS Fetch, Run all, a probe, an alert answered) or a "Try again" sends{ refresh: true }(useOpsSnapshot.js) and gets a build that started after it, so the action shows at once on any worker: one starts at once when none is running, and refreshes arriving while one runs share the single build queued behind it. On the settings side, theIf-Matchbranch #1181 added could not be reached from a browser:lib/auth/cors.jsnow allowsIf-Match(the allowlist is exactly Content-Type, Authorization, If-Match) and exposesETagbesideWWW-Authenticate;GET cms/platform-settings/{setting}returns the document'setagin the body and as anETagheader; and the Platform Settings tabs (settingShared.jsxuseSetting) and the Mailing List'sNewsletterSettingsCard.jsxsend it back asIf-Match, hold the one each save returns, and on a 412 or 428 say the setting was saved elsewhere and reload it. The precondition is now required: a save that names no version may only create the document (createDoc), a stored setting is replaced only byreplaceDocIfMatchunder the version named (412CONFLICTotherwise, also when another write lands between the read and the replace), and replacing one withoutIf-Match, or withIf-Match: *, which names no version, is a 428PRECONDITION_REQUIRED. Those two pages were the only callers of the write; no script, Telegram command, MCP tool or timer calls it. The reminders sheet keeps its merge under its own read (review of #910), so its read offers noetagand a timer stamp landing mid-edit is still merged rather than refused. - Alert fire record: twelve of fifteen rules seen to fire, and the SSH
burst test writes lines rsyslog cannot fold (PLAT-4, #964). The fire
tests ran on 2026-10-15 from a detached runner (
Invoke-AlertFireTest.ps1, one test at a time, every change put back and checked), and the Fire record indocs/runbooks/alerting-and-support.mdcarries a dated row for each rule that opened: the two Key Vault rules, both role-assignment and both role-definition rules, the Static Web App bandwidth, the poison queue, the lab vault unwrap, the lab agent offline and the SSH burst, with the 2026-10-11 Key Vault config write. The SSH burst test's first run failed for a reason in the test: it wrote a thousand identical lines withlogger, and the host's rsyslog ($RepeatedMsgReduction on, Ubuntu's default) folded them into two Syslog rows against a rule that needs fifty; each line now carries its own port (Invoke-AlertFireTest.ps1, its Pester test and the runbook say why), and the re-run opened the alert in about fourteen minutes. Still open:alert-timer-overdue, which has never evaluated (fixed in its own pull request and re-run after the apply), and the two backup-missing rules, whose test stops a backup timer for two nights. - A lab job running when the host stops is interrupted and reported
within seconds, not left
claimed(LAB-13, #959). This replaces the 210 s drain #1178 added: that drain then exited with the job's container still running and nothing reported, so the job satclaimeduntil its 15-minute lease ran out. On SIGTERM the agent now aborts every running job: the runner kills thedocker runchild and starts removing the container by name through the agent's Docker proxy (docker rm -f, the one DELETE the proxy allows). Each job is reported at once, without waiting for that removal, which may take 10 s:failed, exit -1, with The lab host was restarting and this job was interrupted. Submit it again. The agent then waits up toLABS_AGENT_SHUTDOWN_TIMEOUT_MSfor those reports (default 5000; anything but a positive whole number is the default), sendsoffline, and exits. Itsstoppingandofflineheartbeats time out at 5 s each, and the timeout now covers the token request too. A claim already in flight is waited for within the same budget, and the job it brings back is reported without being started. The job loop moved fromvps-agent/index.jsintovps-agent/lib/shutdown.js, so the shutdown has tests. Thelabs_agentunit setsKillMode=mixed, so systemd signals the agent and never thedockerCLI under it, andTimeoutStopSec=30(was 240). The newlabs_agent_shutdown_timeout_msis written to the env file and listed in.env.example.scripts/lab-job-limits.test.mjschecks that 30 s covers the 5 s budget plus both heartbeats, and that the env template renders every limit.tests/hcw-labs-agent.service.test.shrenders the unit and env file with the role's values in CI. The containers are the unit's to clean up, not the process's: a removal can take 10 s, longer than the 5 s window, and a crash removes nothing. The unit therefore runs the new/usr/local/libexec/hcw-labjob-sweepas root after the agent exits and before it starts, removing every container labelledhcw.lab-jobthrough the daemon's own socket. It judges by what is left running, since the agent's own removal may still be at work, and fails only when a container is still running at its 20 s deadline. The proxy allowlist is unchanged, andtests/hcw-labjob-sweep.test.shruns the sweep in CI against a stand-in fordocker. On the host after the nextbootstrap.sh. Two LAB-13 asks stay open on #959: reboot only when no job runs, and a hash-locked ansible-core. - The admin menu is cut to the signed-in role, the role is printed under
the brand, and a control above that role says so before the round trip
(AP-F4, #928). Every item in
frontend/src/config/adminNav.jscarries aminRole, read from the role the server's guard asks for on the routes the page exists to call and recorded item by item in a table beside the registry (viewer: Dashboard and Health; publisher: Publish; editor: the other twenty-five); a row without one fails at module load, as the server'srequireRolefails on a misspelt role.visibleNavFor(role)filters the groups and drops one left empty, andAdminLayout.jsxand the dashboard's Explore strip (DashboardPage.jsx) both read it, so a viewer no longer learns a page's role from its 403.AdminAuthGuard.jsxhands the role it verified down throughhooks/useAdminRole.jsx, one value for the whole portal rather than a second status check per consumer, and the rail and the phone drawer print it as "Your role: …".components/admin/RoleGate.jsxwraps a control the signed-in role cannot use —aria-disabled, the click blocked, the reason as visible text the control names througharia-describedby, so it stays in the tab order and explains itself — and sits on the actions the server gates above the page's own role: Publish and Unpublish, Set slug, Re-host selected and Delete Live Page; Delete Rejected Now and Delete Permanently on the queue; Acknowledge, Resolve and Reopen on the Health alerts; the Recording Hub's transcript Approve, Return to draft and host Retry; the newsletter's Approve and schedule, Send test, Reschedule, Cancel send and Retry; Listen & Learn's Approve and publish; the calendar's Reschedule and Unschedule; and the dashboard's Recount (an editor write on the one page a viewer opens first). The server's guard is unchanged and still answers every request on its own; the gate is what the admin reads instead of the 403. - Polish defects that compound on a phone: no browser confirm dialog,
toasts that stack and announce, a toaster that mounts with the admin
shell, a loading state with a status role, one status request per user,
and a catalogue failure that is shown (AP-F12, #936). The three
window.confirmcalls that sat beside the shared modal (frontend/src/components/admin/ai-engine/McpOAuth.jsx,components/admin/KeywordMatrixPanel.jsx,pages/admin/AIEnginePage.jsx) and the Drafts page's navigation guard (pages/admin/drafts/useUnsavedGuard.js) are state rendered withConfirmModal(ADR 0033 §2); the guard keeps the cancelled click and replays it on confirm, sinceuseBlockerneeds a data router the app does not mount, andbeforeunloadstays the browser's.ConfirmModalgained abusystate: the confirm button disabled under a progress label and the dialog not dismissable while the confirmed request runs, used wherever the action awaits one.src/no-window-confirm.test.jsreads every source file undersrcso the dialog cannot return.components/ui/use-toast.jsholds three toasts instead of one, each expiring on its own clock, andtoaster.jsxno longer makes the viewport one atomic live region (every arrival re-announced the list and the toast it replaced was never announced); Radix's per-toastrole="status"region announces each. The toaster is a static import ofpages/admin/AdminLayout.jsxrather than a lazy Suspense child ofApp.jsx, so a toast fired before its chunk arrived cannot expire unseen; no public page fires one. A sharedcomponents/ui/spinner.jsx(role="status", a label) replaces the bareanimate-spinrings inAdminAuthGuard.jsx,PublishedPage.jsxand the two newsletter settings cards.hooks/useAdminAuth.jskeeps one in-flight promise per user in its cache, shared by every mounted instance, dropped byclearAdminStatusCache, and never caches a failure;lib/api.jsno longer forces a token refresh forgetCurrentAdminStatuson every call — a page load takes MSAL's cached token, renewed when it has actually expired, and only an explicit re-check asks for a fresh one (freshToken), the case where a just-granted App Role matters.AIEnginePage.jsxshows a dismissiblerole="alert"notice when the model catalogue cannot be read, naming what still works and a Try again, where it used to log and show nothing. - AP-F11: the site's Content-Security-Policy drops
'unsafe-inline'and the scheme-wide image grant and gainsbase-uri,object-srcandform-action, the admin token cache moves tosessionStorage, and no source map ships (AP-F11, #935).frontend/staticwebapp.config.jsoncommits a policy with no'unsafe-inline'anywhere and animg-srcof the hosts the site was measured to load images from — the media route, the certification badge hosts, the show's artwork host and the three hosts published articles still hotlink;data:stays for the upload previews and two article bodies,blob:goes — besidebase-uri 'self',object-src 'none'andform-action 'self' https://login.microsoftonline.com.frontend/scripts/stage-swa-config.mjsfinishes the policy after every build instead of copying the file: one'sha256-…'per distinct inline<style>body instyle-srcand one per distinctstyleattribute value instyle-src-attr 'unsafe-hashes', computed from the pre-rendered pages, so a change to a component'sstyleprop updates the header on the next build (2026-10-15: 2 elements with one body and 2,969 attributes with 80 values across 230 documents).frontend/scripts/check-image-hosts.mjsruns next and fails the build when a page, a stylesheet or an article body references an image hostimg-srcdoes not name, tolerating the retired Firebase bucket only until the last page stops naming it; the measurement and how to repeat it are indocs/architecture/how-it-is-built.md.frontend/src/lib/msalConfig.jscaches MSAL insessionStorage, its own default for a single-page app, so a DOM XSS or a compromised dependency can no longer read a token pair that outlives the tab; the known risk is an in-app browser that loses the session across the redirect, the verification is the owner's sign-in on an iPhone after the deploy, and the rollback is that one line.deploy-azure-frontend.ymlmoves every.mapout ofdistafter the build, keeps them for thirty days as the run'ssource-mapsartifact so a stack trace can still be read, and refuses a payload with one left;frontend/scripts/deploy-source-maps.test.jsholds the workflow andvite.config.jsto each other. - The drift monitor's Terraform row reads the last apply from HCP
Terraform, and reads ⚠️ rather than ✅ when it cannot (PLAT-3, #963).
The row #1178 added to
scripts/check-deploy-drift.mjstook the last successfultfc-plan-check.ymlrun's head as the deployed baseline. That workflow is a dispatch-only read of a plan and is green withTFC_TOKENunset, on a plan nobody confirms and on a plan the owner never applies, soinfra/could keep merging while the row printed ✅, and an apply from the HCP Terraform UI with no dispatch in a day read ❌ (found by a read-only review, 2026-10-15). The row now resolves thehcw/hcw-azureworkspace and reads its newest run in stateapplied(filter[status]=applied,page[size]=1,include=configuration_version.ingress_attributes), takes thecommit-shaof that run's ingress attributes as the deployed baseline and itsstatus-timestamps.applied-atas the date, and counts theinfra/commits onmainafter it as the other rows do; the walk throughincludedischeck-tfc-plan.mjs's, and the API call istfc-workspace-busy.mjs's, both imported rather than copied. A baseline commitmaindoes not contain is reported as such and fails the report instead of counting as zero drift; no token, or a read that fails, renders ⚠️ with the reason and fails the job, never ✅.monitor-deploy-drift.yml'scheckjob sits in theautomationenvironment and passesTFC_TOKENto the step, the waytfc-plan-check.ymlreceives it; its permissions are unchanged. The entry'scosmos-containers.jsonpath, which named a file that does not exist at the root (infra/cosmos-containers.jsonsits underinfra), is gone, and the report's footer describes three rows rather than "both deploys". Tests cover the token-unset row, an applied baseline with laterinfra/commits, a refused read, a baseline offmain, a workspace that never applied, a listing whose newest run is not applied, every declared path existing on disk, and the workflow wiring. - Recording Hub saves again: the recordings create allowlist accepts the
four fields both forms send, typed (AP-B6, #941). #1181 put an allowlist
on
POST cms/recordings(RECORDING_CREATE_FIELDSinfunctions/src/lib/admin-integrations.js) and left outsource,plaudId,recordedAtandstatus, which the only two callers send on every save, so "route a Plaud recording" (recordingCards.jsx) and the pasted-transcript form (uploadForms.jsx) both answered 400 from that deploy on; the frontend tests mock the API and could not see it. The four keys are allowlisted and typed:sourceis one ofplaud_mcpandmanual_upload,plaudIda non-empty string of at most 200 characters,recordedAtan ISO string or epoch milliseconds stored as ISO, andstatusis accepted only asnew, the value the handler stamps anyway, so the body still cannot choose a recording's state. Tests post each form's payload verbatim and expect 200, so a field that leaves the list again fails here. -
The content list's
searchis answered from the index, bounded, and reads both summary casings (AP-F6, #930). The predicate #1178 added wrapped each field inLOWER(), which Cosmos DB documents as not using the index, so every search loaded all ~70kcontentdocuments across every partition. It is nowCONTAINS(field, @search, true), the case-insensitive form the index serves (a full index scan of the path's distinct values, not a full scan of the container), and the parameter is no longer lowercased. The term is bounded assortandlimitalready were:LIST_SEARCH_MIN_CHARS(2) toLIST_SEARCH_MAX_CHARS(100), measured after trimming, outside which the list answers the same{ error }400 an unknown sort field gets; a blank term is no search.Summary, the second casing the module header names, is searched besidesummary, so a record whose summary lives only there can match.cms-content.test.jspins the clause, the parameter's casing, both bounds at their edges with a blank term andq, and a row whose summary lives only inSummary. No admin page sendssearchto the server yet: the Editor list and Live Pages still filter the 500 rows they fetch, so the bound has no UI mirror to add until the search moves server-side. -
PLAT-9 remainder: the Well-Architected assessment's thirty-seven
Plannedcontrols read against the repository and the live estate, and the lab host page's Provisioning and Configuration rows read live (PLAT-9, #969).docs/architecture/well-architected-assessment.mdcarriedPlannedon thirty-seven of its forty controls since the baseline; each now carries what exists and the date it was read: six areVerifiedwith their signal captured (service and API authentication, secret protection with three rules seen to fire, the budget's test notifications, observability's cap and billed ingestion, the migration log), fourteenImplementedwith the missing measurement named (cold starts, cache-hit ratio, Core Web Vitals, RU by partition, a live anonymous fetch), seventeenPartialwith the gap named (no rollback drill or retained Functions package, one HCP Terraform identity for plan and apply, no correlation ids on audit rows, no queue age or depth signal, no dashboards or SLOs), two of themby decision(Private Link, required approvals). The header says what each status means. On the lab host page the Provisioning row says what a session can and cannot read about thehcw/hcw-labworkspace and carries the VPS's Arc state, and the Configuration row's last converge reads from the host'sapplied-commit.json(2026-10-14 05:08 UTC,de0be17a) instead of a week-old date. - PLAT-10 remainder: the Static Web App bandwidth comment reads the live
DNS, the runner address is checked before it reaches a firewall argument
and the storage window's flip can be measured, Cloudflare's ranges are
held to the published list by a weekly pull request, the content account
drops its localhost origin, the registration monitor names its real
identity and what it counts, and the export window's effect on API latency
is an alert rule (PLAT-10, #970).
infra/frontend.tfno longer says Cloudflare caching makes a bandwidth overage unlikely: the apex and www resolve straight to the Static Web App (read 2026-10-15), so every byte counts against the Free plan's 100 GB, andalert-swa-bandwidthis what watches the pace.deploy-functions.ymlpasses the address it learns from a third party throughscripts/assert-ipv4.mjs(one dotted quad or the step fails naming what arrived) at all three places it is used, says what Microsoft documents about who writes the package on Flex Consumption, and leaves the host storage account's default action at Deny through the window when the repository variableFUNCTIONS_STORAGE_OPEN_DEFAULT_ACTIONis the literalfalse, so one deploy measures whether the flip is still needed.scripts/cloudflare-ranges.mjsreads Cloudflare's published IPv4 list, refuses a short or malformed answer, and rewrites the default ofcloudflare_ip_ranges;cloudflare-ranges.ymlruns it on Tuesdays and opens thechore/cloudflare-rangespull request through the App, with the same recorded-paths guard as the lab pin sets (the set lives inopen-lab-pin-pr.mjs; the pull request body says apply before the next deploy, becauseassert-origin-lock.mjsholds every deploy to the list).infra/storage.tfdropshttp://localhost:5173from the content account's CORS: nothing in the frontend fetches from that account by script, and the images and media it loads need no CORS.monitor-functions-registered.ymlstops saying its identity can write site config (that split landed as #1098) and counts the host storage account's IP rules under that name, since storage IP rules carry noci-*names.alert-export-window-latency(infra/observability.tf, severity 3, daily) compares the API's P95 inside the nightly Cosmos export window with the rest of the day and fires when the window is above 1 s and above twice the day's over at least 20 requests: ADR 0019's revisit trigger, measured, as a rule rather than the weekly readout the review asked for, because a readout from GitHub needs a workspace query grant the owner declined on 2026-10-11. The rule is a declared create inassert-expected-plan.mjsuntil the owner's apply, and has its rows in the alerting runbook and ADR 0019. - Completed low-hanging fruit remediations across Admin Portal, VPS agent, deployment drift, docs, and infra (AP-F6 #930, LAB-13 #959, PLAT-3 #963, PLAT-9 #969, PLAT-10 #970).
- AP-F6 (#930): Added server-side
searchquery parameter support infunctions/src/lib/cms-content.jswith Cosmos DBCONTAINS, added 500-item truncation notice and retry attempt state inLivePagesPage.jsx, and added unit tests inLivePagesPage.test.jsx. - LAB-13 (#959): Extended VPS agent shutdown timeout to 210s (
LABS_AGENT_SHUTDOWN_TIMEOUT_MS) with active job terminal completion handling, added systemdTimeoutStopSec=240tohcw-labs-agent.service.j2and role defaults, and hash-lockedansible-corewheel SHA256 inlab-host/bootstrap.sh. - PLAT-3 (#963): Added Terraform Infrastructure (
infra,cosmos-containers.json,tfc-plan-check.yml) to deploy drift monitor (scripts/check-deploy-drift.mjs) and test suite. - PLAT-9 (#969): Updated Azure Well-Architected assessment (
well-architected-assessment.md) reflecting single Function App consolidation (ADR 0019) and verified recovery drill controls. -
PLAT-10 (#970): Corrected apex DNS direct routing comment in
infra/frontend.tfregarding SWA 100 GB Free bandwidth limits, and added strict IPv4 format validation for runner IP lookup in CI deployment workflows. -
Lab host: no reboot with a user logged in, the upgrade origins rendered by the role, and job containers with a stated swap bound and an open-files ceiling (LAB-13, #959). The hardening role's unattended-upgrades override now clears and names the allowed origins itself (
hardening_unattended_allowed_origins, Ubuntu's four) and setsAutomatic-Reboot-WithUsersto false, with a test that renders it; the daily held-package report logsa reboot is pending since <date> (<n> days)at warning while the reboot waits, and the runbook names the owner's window (log out before 04:30, or reboot when convenient). The job runner writes--memory-swapas twice--memory(Docker's default, swap at most the memory limit, made explicit) and adds--ulimit nofile(LABS_AGENT_JOB_NOFILE, default 1024, also in thelabs_agentrole); both flags are sandbox-controlled, so a capability cannot set them. On the host after the nextbootstrap.sh. The rest of LAB-13 (a terminal status for a job killed by a reboot, reboot only when no job runs, a hash-locked ansible-core) stays open on #959. - The Terraform busy guard fails closed (PLAT-3, #963).
scripts/tfc-workspace-busy.mjs, whichdeploy-functions.ymlruns before any firewall window opens, used to pass with a note whenTFC_TOKENwas absent, so from the outside a deploy log read the same whether the guard was off or the workspace was idle. It now refuses the deploy and names the secret to seed, and prints "armed" when it can read the workspace.TFC_TOKENhas been a repository secret since 2026-10-08 (#980), so the deploy job already receives it. The runbook and the workflow comment say so. functions_scm_lock_enableddefaults totrue(PLAT-10, #970). The workspace set ittrueon 2026-08-25, when the SCM window was proven underDeny, and the Function App's SCM default action readDenyagain on 2026-10-14, so the code default now says what the estate does and the plan for the change is empty. The comments ininfra/variables.tf,deploy-functions.ymlandscripts/assert-origin-lock.mjs, and the required-inputs row, say so instead of describing the 2026-08-25 posture.-
Version floors: the weekly pull request moves the
engines.noderanges with the floors, and arrives green.update-version-floors.ymlused to movescripts/version-floors.jsonalone and open a pull request that was red by design, so every Node.js minor left fiveengines.noderanges for a person to move by hand (#1152). The updater now moves each range the new floors leave behind within its line, in itspackage.jsonand the root entry of thepackage-lock.jsonbeside it, and re-reads the tree to list only what is still behind: an exact pin with a digest or checksum, aruntime_version, aruns-on, or anything on a new line. The commit job judges the patch before applying it, by a rule written in the workflow: the floors file, and otherwise only a"node": "<range>"line of apackage.jsonorpackage-lock.json. The pull request body now follows.github/pull_request_template.md, and its first item says whether the floors check is the evidence or the work list.scripts/version-floors.test.mjsalso fails a lockfile whose rootengines.nodediffers from itspackage.json. -
Lab host: a run can be narrowed to the roles that changed. Every role in
lab-host/ansible/site.ymlcarries its own name as a tag, sobootstrap.sh --tags lab_images,coderafter a digests merge, or--tags addonsafter an add-on's digest moved, re-applies one area of a converged host instead of the whole play; the pre-flight checks and the privilege checks run whatever the tags. A narrowed run is not a convergence: neitherbootstrap.shnor the play records it as a completed run, so the site keeps saying the host is behind until the next full one.ansible.posix.profile_tasksprints the twenty slowest tasks after the recap, so a long run says where its minutes went.lab-host/README.md, "Running part of the play", has the tags and the two things a narrowed run is not. Owner ask 2026-10-14, after a run that took most of an hour to move three pins. - Lab host: the proxy check reports in plain words. The privilege check
used to print the three probes' raw answers, two of which the Docker CLI
words as
Error response from daemon: … refused …— the proxy doing its job, read as a fault. It now prints one sentence saying what passed; the raw answers appear only when the check fails.
Fixed¶
- Lab host: a host firewall for the lab sandbox, the container bridges and
Coder's internal network (#954, #958). A new
host_firewallrole, run aftercoder_sandbox, adds the host's ownHCW-SANDBOX-OUT,HCW-BRIDGE-IN,HCW-CONTROL-OUTandHCW-REFUSEchains, jumped to first above ufw's. - The sandbox: the sandbox daemon's user (the daemon and every workspace) may reach DNS on systemd-resolved's stub, TCP 443 on the host's own non-loopback addresses, and TCP 80/443 on public addresses. Host-local, private, link-local, multicast and reserved destinations, and all other traffic, are refused at once, and flows already open to them are cut when the rules first load.
- The bridges: containers on
docker0andbr-*may open only TCP 80/443 to the host. - Coder's internal network: the host's own new connections to it are refused; Coder reaches it across its own bridge.
- Loading:
hcw-host-firewallloads each family in oneiptables-restore --noflushtransaction and keeps each jump first and single. Both families are dry-run before either commits, a jump to an undeclaredHCW-*chain is refused first, and a lock in/runkeeps one run at a time. The play fails on any read-back difference, and check mode reports drift. - Boot:
hcw-host-firewall.servicere-applies the rules at boot, after ufw and before Docker, and reports a failure tohcw-unit-failed@. The sandbox user's manager requires it. - Failing closed: if a run cannot load the firewall, the sandbox
keeps running only while the live rules exactly equal the last policy
the role verified live (
/etc/hcw/firewall/verified); otherwise its manager is stopped and the play fails. - Removal:
host_firewall_state: absentremoves it all in a safe order. - Untouched: ufw's rules and the add-ons'
DOCKER-USERrules. - Tests:
roles/host_firewall/tests/hcw-host-firewall.test.sh(real iptables in a scratch network namespace, 106 checks, in CI with ufw required) andscripts/lab-host-host-firewall.test.mjs. - No auto-delete on the owner's Ambassador hub (follow-up to #1234, SEC-4
#973). #1234 set a 90-day container TTL on the
ambassadorCosmos container, believing it held applicants' personal data. It holds the owner's own programs, applications and evidence, kept until deleted. A container TTL expires every document not written within the window, so it would have deleted the owner's records. The tfc-plan-check flagged it as undeclared before any run carrying it was confirmed, and it is taken off before any apply.generate-cosmos-container-spec.test.mjsnow refuses a container TTL on the containers whose records are kept until deleted (ambassador,content,content_versions,blogs). The data-subject runbook keeps the hub in scope as a search of its free-text fields and files, redacting matches and keeping the records. Itscontentqueries and its exercise record, which do not match the estate, are marked under review and unverified until #1237. - Lab host: Caddy's admin API moves to a Unix socket, and HTTP/3 is off
(#958). The Caddyfile always renders a global block with
admin unix//run/caddy/admin.sockinstead of TCP127.0.0.1:2019, andservers { protocols h1 h2 }, so nothing listens on UDP 443 (ufw already dropped it).caddy.servicecreates/run/caddyasRuntimeDirectory=caddy(0700), and itsExecReloadpasses the socket as--address, which only thecaddyuser and root can open. The first run restarts Caddy before any reload, through handler order across the caddy, coder and addons roles, which a test holds; and a running Caddy without its socket is restarted.caddy-adapt.test.shreads the admin listener and protocols back from the pinned Caddy, andscripts/lab-host-caddy-admin.test.mjsholds the unit, the socket path and the handler order. Runbook: "Caddy: reloading by hand". alert-kv-config-writeno longer fires when one of its own alerts is closed (#1216). An alert's state change is logged against its target, as<vault id>/providers/Microsoft.AlertsManagement/alerts/<id>. That sits beneath the vault, and the rule's only filters were category and status. So closing one of its alerts opened another and paged again: on 2026-10-16 the alert cleanup closed three, and three new ones fired at 01:37Z. Activity Log conditions have no "not equals", so the rule now names the providers that make real vault changes: KeyVault, Authorization, Resources (tags, which the fire test writes) and Insights (diagnostic settings).Microsoft.AlertsManagementis not among them.scripts/activity-log-alert-filters.test.mjsrefuses any Administrative Activity Log alert without a provider or type filter, or one that letsMicrosoft.AlertsManagementthrough. Live after the owner'shcw-azureapply.- The lab backup account's blob diagnostic setting no longer plans a
change on every run (#1209). It set
log_analytics_destination_type = "Dedicated", which Azure keeps no value for on a storage target. Storage writes only to its own resource-specific tables, so the setting read back empty after its create on 2026-10-14, and every plan since proposed"" -> "Dedicated"again. The plan check named it as undeclared on #1207's merge run (run-qrsPoBGgTrAZyqwa). The attribute is gone. The rows land inStorageBlobLogsas before, as they always did for the site's four other storage settings, which never set it.scripts/storage-diagnostic-settings.test.mjsrefuses the attribute on any diagnostic setting whose target is a storage account. - The
/educationpage test computes its expectations on the day the page renders, not on the runner's real date (#495, #1092). Since #495,EducationIndexPage.test.jsxhas pinned the clock to 2026-09-10 inbeforeAllbut readtodaywithtodayIso()in the file body. Vitest runs a file's body while collecting it, before any hook, so the page rendered on 2026-09-10 while the expectations used the runner's own date. The two agreed only while no row's status differed between those days. AB-400 was the first row that did:availableDate2026-10-16 andstatusReadDate2026-10-12 make itupcomingon the frozen day andactivefrom 2026-10-16. From midnight UTC that day the Linux runners expected 49 bookable Azure exams and rendered 48, which failedcoverage (qlty)on run 38834846071. Thefrontendrow passed only because its path filter skipped its tests on that docs-only pull request. A machine on Central time still read 2026-10-15 and passed.todayis nowtodayIso(FROZEN_NOW), worked out from the frozen constant, and a new first test rendersuseToday(earliestDataAsOf()), the call the page makes, and requires it to return that same day. A clock mismatch now fails on the first day the two clocks differ, not on the day a status happens to change. The page needed no change: it reads oneuseTodayand passes it everywhere. A local matrix of four collection-time clocks and six zones, UTC−12 to UTC+14, failed the old file in 19 of 24 cells; the new file passes all 24. - Monitor Functions Registered no longer fails on a storage window that a
running deploy holds (#1205). On 2026-10-15 it read the host storage
account at 22:45:38Z, while deploy run 38819173840 held its upload window
(22:45:18Z to 22:47:35Z). It reported
Allowand one IP rule as ❌, and the window then closed on time. Now an open window is believed only once nodeploy-functions.ymlrun is in progress. While one is, the monitor reads the firewall again every 30 s for up to twenty minutes and passes as soon as the window closes. With none running, it reads again a minute later. An orphan stays ❌. A deploy still holding the window after the wait is ⚠️ and named, and so is one GitHub stops answering about for three polls. A Node crash is not taken for a deploy in progress.scripts/check-functions-deploy-in-progress.mjsanswers the question, with a 15 s timeout and one retry. Its test runs the monitor's own step underbash -e, against a stubbedaz, through that collision and the cases around it. The monitor does not join the deploy's concurrency group. There it would queue behind a deploy waiting on approval, and under the default queue a newer pending run cancels the older one. - Operator documentation drift (PLAT-9, #969).
infra/README.mdsaid not to pre-build hub networking whilehub.tfexisted; the cost analysis called the Management budget "declared, not applied" after the apply had landed (read live 2026-10-14); ADR 0028 section 7 said host storage stays LRS when ADR 0031 (2026-09-14) chose GRS and Terraform sets it; and the lab host page's summary and identity rows still read "planned" for a host that has run since 2026-09-26. Each line is corrected with the read that corrects it, and a docs-build gate,scripts/docs/check_stale_state.py, fails the build on the stale phrases the review found an operator acting on ("State: planned", "not yet armed", "delivery is unproven"). - The route error boundary recovers on navigation, and a failed sign-in
shows its code (AP-F7 #931, AP-F8 #932). One render error on any admin
route kept the failure card on every admin route until a hard refresh,
and always blamed stale build chunks; the boundary now resets when the
path changes, tells a failed dynamic import from a runtime error in its
copy, and shows the error message on admin routes. The sign-in callback
page said "the browser console has the detail", which a phone cannot
open; it now shows the Entra or MSAL error code with a Copy button that
puts the code and the full message on the clipboard, while the message
itself, which names the authority and client id, stays off the screen.
App.boundary.test.jsxandAuthCallbackPage.test.jsxpin both. - Admin dashboard and Editor list say when a read failed (AP-F5 #929,
AP-F6 #930). The dashboard rendered "You're all caught up" over zero
counts when the snapshot call failed, with a small red line beneath; it
now shows an error state with Try again, and a later failed re-read
keeps the last numbers with a "Numbers may be stale" badge and a Retry
button beside the reason. The Editor list ignored its three reads'
errors, so a 401 or a 5xx rendered as "No items match your filters"; it
now shows the failure with Try again, which re-reads all three boards,
and a board that comes back exactly at the server's 500-item limit
carries a banner saying older items are not in the list or its search.
DashboardPage.test.jsxand a newEditorListPage.test.jsxpin both. - The Publer proxy forwards only the paths and methods the Social Hub
sends, and every write through either proxy leaves an audit row (AP-B3,
#938).
publerProxyforwarded any verb to any Publer path with the server's key and no trail, so an editor session could reach accounts, members and workspace settings the Social Hub never touches.integrations-http.jsnow names its five endpoints and three methods (GET, POST, DELETE), andrest-proxy.jsrecords every non-GET call, refused ones included, inadmin_audit_logswith the actor, the path as sent and the outcome.rest-proxy.test.jssends every request the pages make and asserts an unlisted path is refused for every verb. - Landing zone HCL: the AWS and GCP emitters no longer hand state to
generators that take none. Eight calls in each of
frontend/src/lib/landingZone/hcl/aws/index.jsand.../gcp/index.jspassed the normalised state to functions declared without a parameter, which CodeQL raised as sixteenjs/superfluous-trailing-argumentsalerts after #1157. The arguments are dropped; the emitted files are unchanged. - Lab host: the add-on secrets are read with the
varslookup. Theaddonsrole built each add-on's secret environment with thevarsdictionary, which ansible-core 2.21 warns about on every run and 2.24 removes; it useslookup('vars', …)now, so the run prints no deprecation warning and does not break on the next ansible-core. -
Lab image: the pins the weekly scan could move (#1133). The first scan the
checkjob filed (2026-10-14) named fixable HIGH/CRITICAL findings in the images the host runs. Four of them have a publisher release to move to, andlab-image/versions.envmoves them with the sums read as the file documents: terraform 1.16.4 → 1.16.5 (releases.hashicorp.comSHA256SUMS), the azurerm provider 5.7.0 → 5.9.0 and azapi 2.12.0 → 2.13.0 (the registry'sdownload/linux/amd64shasum), and the Azure CLI 2.90.0 → 2.91.0 (published in both thebookwormsuite the full target installs from and theresolutesuite the sandbox template uses), which is the bundle that carried PyJWT 2.13.0, cryptography 48.0.1, msgpack, setuptools 70.3.0 and urllib3 2.7.0. The rest of the table — azurerm 4.81.0, alz 0.22.0, random 3.9.1, modtm 0.4.0, time 0.14.2, kubeconform 0.8.0, helm 4.3.0, kubectl 1.37.1 — is already each publisher's newest release, so those Go-library findings have nothing to pin yet, and the Debian lines (openssl, libpcre2~deb13u3) arrive with the base digest the weekly bump moves. TheREADME's mirror line follows. -
The lab agent's Docker proxy follows a daemon restart. The proxy (
hcw-labs-agent-docker-proxy, LAB-5) holds the daemon's socket as a bind-mounted file, so when a bootstrap run restarted the daemon under it — the 2026-10-14 run that took Docker Engine from 29.8.1 to 29.9.0 — it kept the old, unlinked socket inode: its own refusals still answered and every forwarded call got HAProxy's503 Service Unavailable, and the run ended at the privilege check. Thelabs_agentrole now installs/usr/local/libexec/hcw-docker-proxy-stale, which compares the proxy's start withdocker.service'sActiveEnterTimestamp, restarts a proxy that predates the daemon's current start, and then requiresGET /_pingthrough the proxy (ansible.builtin.urion its Unix socket) to answer 200 before the play goes on. The privilege check's failure message names the symptom. Tested against stand-ins forsystemctlanddockerinansible-lint (lab-host). -
The lab supply-chain issue fits GitHub's body limit. The weekly
checkjob scans the two published lab images with Trivy and files its findings as one issue; on 2026-10-14 the full tables ran past the 65,536- character body limit (Body is too long, run 38493246343) and the week's findings were filed nowhere. The scan now writes JSON once per image, the full table (trivy convert) goes to the run summary as before, and the issue gets a compact per-package table — totals by severity, up to forty packages with installed and fixed versions and their first CVEs, a pointer to the run for the rest — behind a hard cut at 60,000 characters that names the run summary.lab-pin-pr-workflows.test.mjspins the two renderings and the cut.
Added¶
- Lab host: an alert on an unexpected SSH login (#953, LAB-7).
alert-lab-ssh-login(severity 1) pages when sshd accepts a login that is nothcwadminby public key with the pinned key (lab_ssh_admin_key_fingerprints). Once the change that closes port 22 setslab_ssh_loopback_only, it also pages on any login that did not arrive through Arc SSH. It parses OpenSSH 10.2'sAcceptedline, drops VERBOSE'sAccepted key/Accepted certificatefollow-ups, and pages on a line it cannot parse. The rule is stateless and unmuted (ADR 0022 decision 6): rows are chosen by ingestion time over 20 minutes and evaluated every 15, inside a one-day window, so no login is missed (even one ingested late after an agent backlog) and one may mail twice. A test keeps the fingerprint variable equal to the key pinned ingroup_vars, andInvoke-AlertFireTest.ps1 -Test lab-ssh-loginfires it. - Scanner triage: a CodeQL section (SEC-5, #974). The triage standard
covered Qlty, zizmor, checkov and Trivy but not CodeQL dismissals, of
which 151 exist: 112 under the vendored harness, dismissed on 2026-08-21
without per-alert notes, and 39 first-party. The section states the
rule (every dismissal carries a one-line evidence note, the owner
dismisses), records the harness dismissals as a class closed by the
codeql-config.ymlexclusion, and writes the evidence for the two first-party dismissals that have no note (#398, #388). - Lab host: the way back from the off-host backups (LAB-9, #955, PR 3).
hcw-backup-restore, installed by thebackupsrole, lists thehost-backupscontainer as the Arc identity, fetches the newest or a named blob to/var/backups/restore/by a.partialname until the listed length has arrived, and with--yesrestores a dump intocoder-postgres(stop, drop and recreate, load, start) or a snapshot into Vault (raft snapshot restore -force, the token on stdin). The reinstall runbook's step 7 gains the restore as its last act and the Last rehearsed / elapsed line, "not yet" until the owner rehearses on a fresh VPS. Two corrections from the first seed on 2026-10-14: Vault 2.0 madegenerate-rootneed a token as well as the recovery keys (CVE-2026-5807), so "The root token" now carries the temporaryenable_unauthenticated_accessswitch and its removal; and the encoded token is read from what the third key prints, since the attempt's status is cleared on completion. Tested with stand-incurl,dockerandvault. - Lab host: the backups leave the VPS (LAB-9, #955, host side). A new
backupsrole, last inlab-host/ansible/site.yml, uploads the nightly Coder dump as it is written and a nightly Vault raft snapshot (hcw-vault-backup.timer, 03:45 UTC) to thehost-backupscontainer ofstlabhybridprodcus01, as the Arc machine's identity:hcw-arc-tokenperforms the agent's challenge,hcw-backup-uploadputs one blob undercoder/orvault/and logs one line taggedhcw-backup-upload, and no storage key is on the host. The snapshot token is the owner's one seed (hcw-vault-backup-seed: the owner's Vault token on stdin, a periodic orphan token under a snapshot-only policy, stored root-only), renewed by the unit itself; until it is seeded the unit is skipped, not failed. The role lists the container as the identity at every run and refuses the run when it cannot. The owner's line is indocs/runbooks/labs-host.md, "Off-host backups"; the restore and the rehearsal are the next pull request on #955. The seven creates #1147 declared inscripts/assert-expected-plan.mjsare removed, applied on 2026-10-14. -
Lab host: where its backups go (LAB-9, #955; owner decision 2026-10-14).
infra/lab-hybrid-backups.tf: a Standard GRS, Cool, Entra-only storage account inrg-lab-hybrid-prod-cuswith one container,host-backups, under a 35-day time-based immutability policy — the host can write a backup and cannot delete or overwrite one inside the window — with versioning and 35-day soft delete behind it; the Arc machine identity's second grant, Storage Blob Data Contributor on that container only; blob read, write and delete logs to the Management workspace; and one alert per backup kind (coder/,vault/) when no blob was written in two days. The account name is an output for the host'sgroup_vars. ADR 0032 gains the amendment of 2026-10-14, which also records the owner's choice over the VPS provider's object storage and local-only, and why uploads are not encrypted client-side. The host side (upload after the nightly dump, a nightly Vault raft snapshot, the restore helper) and the runbook's rehearsal line follow in their own pull requests once this has applied. -
Lab pin bumps carry their dependents (#949). Three files are held to the pins the weekly bumps move, and a bump that moved the pin alone failed its own pull request's checks on 2026-10-13 (#1122
scripts (operations)andansible-lint (lab-host), #1123frontend). Now the host bump that moves Caddy also movesCADDY_SHA512inlab-host/ansible/roles/caddy/tests/caddy-adapt.test.sh, from the release'scaddy_<v>_checksums.txt; the one that moves Docker Engine re-recordsscripts/fixtures/docker-cli-agent-requests.jsonwith the pinneddocker-ce-cli, installed into a temporary directory from Docker's apt index — the signing key refused unless it hashes todocker_apt_key_checksum(the value the docker role holds the host's keyring to), the InRelease verified against that key with gpgv, the index by the InRelease, the .deb by the SHA256 the index lists (scripts/lab/record-docker-cli-fixture.mjs, a step oflab-supply-chain.ymlthat exits with nothing written when the fixture already matches); and the image-base bump rewrites the base tag and CPython release the Docker guide page names (frontend/src/pages/docker/BuildingImagesPage.jsx), and sets that page'sCHECKED_ONto the day of the bump, since the pull request'sfrontendcheck is the read of the page against those files. The three files join their set's paths inopen-lab-pin-pr.mjs, whose pull request body now carries the template's sections in order with only the true boxes ticked (CodeRabbit's Description check warned on #1122 without them). The tests that named those values by hand —lab-pin-bumps.test.mjsagainst the livegroup_vars,DockerGuides.test.jsxagainstpython:3.14.7-slim-trixie— read them from the files instead, so a bump pull request is green on its own and a merged bump does not breakmain. -
Recording Hub: a Stats tab with the show's Podbean statistics. Closes #1115 (the second of two slices; the first added the Distribution tab's live status and downloads). The show's figures lived only on Podbean's site; the hub now reads them through the same client (ADR 0029 §1c).
GET cms/podcast/host/stats?range=30d|90d|12m(editor;lib/podcast/host-stats.js): six independent Podbean reads over the range's window — downloads by day (30d,90d,period: 'd') or by month (12m,period: 'm', from the first of the month eleven months ago), countries, platforms, sources, monthly listener retention and the audience counts (followers,likes,comments,totalEpisodeLengthSeconds). A failed sub-read isnullin its place with its recorded error undererrors.<name>; the others still answer and the response is 200. A range not offered is 400 before any read. Cached 60 minutes per range inadmin_config/podbean_host_stats_<range>;?refresh=1reads live except while the range's cache is under a minute old, and the document carries the client'saccountKey, both as the overview does. Not configured is 200{ configured: false, range, reason }with no call.lib/podcast/host-cache.js: the read-throughadmin_configcache the overview and the statistics now share — served while younger than the TTL; on refresh, served while younger than the floor (read from the shared document, so every session and instance together make one live read a minute); a miss for any other Podbean account; written create-only first and etag-conditionally after; logged by error code and never a dependency when it cannot be read or written.host-reads.jsmoved onto it with no change in behaviour.- Stats tab (
?tab=stats;statistics,downloads,analyticsandlistenersland there): a 30 days / 90 days / 12 months picker, the total downloads and a bar chart with one bar per day or month (zero where nothing was reported, so a quiet day is a bar and not a gap), top countries (named), platforms and sources as ranked lists with share % and anOtherrow past eight, retention as a small table with each point as a percentage, and the audience counts as tiles. Loading, not-configured, failed-read, per-section failure and "no data yet" states each say so. The chart is recharts, as AI Engine → Usage uses. - Two documented-but-unmeasured shapes are taken either way: a monthly
download key of
YYYY-MMis a month andYYYY-MM-DDfolds into its month; aretentionvalue at or under 1 is a fraction and above 1 a percentage. Both are stated instatsView.jsand pinned by its tests, so the first live reading has a test to correct. -
Contract and inventory:
.azure/api-surface.json,functions/function-inventory.json(getPodcastHostStats). -
Recording Hub: Podbean's live status and downloads per published episode. Part of #1115, re-scoped 2026-10-13 after Podbean's MCP server was measured unusable from a web app (redirect allowlist; a developer-app token gets 401 at
/mcp). The Distribution tab showed only what the publish job recorded; it now reads Podbean through the client the publish already authenticates with (ADR 0029 §1c). podbean.jsgains the reads:getEpisode,getDownloadStats,getCountryStats,getPlatformStats,getSourceStats,getUserRetentionandgetAnalyticReports, every one aGETthrough the samecall()with the token asaccess_token. Dates (YYYY-MM-DD, a real day, start on or before end) and periods (d|m,m|w) are refused asVALIDATIONbefore any request.types[]is sent as a repeated parameter; the shapes are Podbean's documented ones and none is measured live yet — the client tests pin the documented requests.GET cms/podcast/host/overview(editor;lib/podcast/host-reads.js): every transcript whosehost.podbean.episodeIdis set, joined by id to one page of the show's newest 100 episodes, plus the listed episodes no transcript names (transcriptId: null— the three published by hand). Per episode: Podbean's title, status, permalink, publish time, and the last 30 days of downloads ({ total, byDay }), newest first and at most 20 statistics reads per overview (downloadsCappedcounts the rest). A Podbean failure lands where it happened —errors.show,errors.episodes(then on every transcript row, and no statistics are read), orerroron one row — in the publish job's recorded-error shape, and the answer is still 200. Cached 15 minutes inadmin_config/podbean_host_overview;?refresh=1reads live, except while the cache is under a minute old, so every editor session, tab and instance together make at most one live read a minute against the bucket publishing shares (the floor is read from the shared document). The cached document carries the client'saccountKey(sixteen hex digits of SHA-256 over the client id, never the id), so settings pointed at another Podbean account make it a miss rather than serving the previous account's rows until expiry. A cache that cannot be read or written is logged content-free and the live overview is served. Not configured is 200{ configured: false, reason }with no call. Logs carry counts and codes, never a title or the token.- Distribution tab: under each "Published to Podbean" record, a Podbean
line with the live status badge (Live / Draft on Podbean), the episode
page, "N downloads, last 30 days" and a 30-bar sparkline; an "Also on
Podbean" group for the episodes this hub never sent; a "Podbean read
(cached)" status line with Refresh. Loading, not-configured and failed-read states each say so; a failed read shows no stale counts. The overview is read when the tab first mounts, not on page load. -
Contract and inventory:
.azure/api-surface.json,functions/function-inventory.json(getPodcastHostOverview). -
MCP Disconnect revokes the grant at the provider (RFC 7009). Closes #1116, raised by CodeRabbit on #1114. Disconnect on an OAuth MCP server deleted the stored token pair and left the grant live at the provider until its refresh token expired or the owner revoked it there.
- Discovery keeps the
revocation_endpointthe authorization-server metadata names (RFC 8414), https only, on the pending sign-in and then on the stored connection (oauth.revocationEndpoint), beside the token endpoint — so, like the token endpoint, it is set only by the flow and never by a config write. A missing or non-https one is stored as none; it is not a reason to refuse the sign-in. Podbean advertiseshttps://mcp.podbean.com/revoke(read 2026-10-13); Replicate and Hostinger did not when read. - Disconnect revokes first, then deletes. The refresh token, then the
access token, each POSTed as
token+token_type_hintwith the client authentication the registration chose (client id in the form for a public client; Basic or in the body for a confidential one), through the same guarded request as every token call: https only, no redirects, one deadline. The local delete happens whatever the provider answers, so an outage cannot keep a token on this side. The response carriesrevoked(true,falsewith a shortreason, ornullwhen there is nowhere to revoke) andissuer; the audit row records both. No token reaches a log or the response. -
The card says which. "Disconnected and revoked at mcp.podbean.com", or "Disconnected here; the grant may still be live at mcp.podbean.com — revoke it in your mcp.podbean.com account". A provider without a revocation endpoint gets the plain "disconnected" toast as before. The Podbean card's notes and the Connect runbook say the same.
-
Podbean's MCP server in the AI Engine (Admin → AI Engine → MCP Servers). The show moved to Podbean on 2026-10-13, and Podbean runs a hosted MCP server at
https://mcp.podbean.com/mcp. It covers the show's episodes, its details and its listener statistics. - Sign-in: read that day, the server advertises RFC 9728 and RFC 8414
discovery, PKCE (
S256),noneclient authentication and aregistration_endpoint. So the existing Connect flow signs in with no key: press Connect, sign in at Podbean, approve. The token is kept server-side and renewed by the MCP token timer, like Hostinger's and Replicate's. - Ships switched off. Its tools can publish, edit and permanently delete episodes.
-
Where it is recorded:
seed.jsseeds it (the AI Engine page adds a missing default on load). The card treats it as built-in. The credentials register lists its OAuth token asmcp-podbean. The tests pin it among the Connect servers. -
podcast.hybridcloudworks.comfor the show's Podbean website. Hybrid Cloud Insights moved from RSS.com to Podbean on 2026-10-13. - The feed: it is now
https://feed.podbean.com/hybridcloudinsights/feed.xml. RSS.com's feed and Podbean's first address (spatino) both answer 301 towards it, and all three episodes keep their GUIDs. - The record:
cloudflare_dns_record.podcast_podbeanpointspodcastatpbcdn2.podbean.com, DNS-only likedocs, because Podbean issues the certificate itself. - Why a subdomain: Podbean's "Use a domain I own" page prefills
www, which is the site. The comment ininfra/frontend.tfand the edge DNS runbook say so, so the podcast never takeswwwor the apex. -
The plan checker declares the one create.
-
The hourly functions monitor names a
ci-*access window on the Function App whose run has ended (#1050).deploy-functions.ymlandpublish-content-manifest.ymlopen a one-host Allow rule for their runner and close it in analways()step, which does not run on runner loss or a force-cancel.assert-origin-lock.mjsaccepts any single-hostci-*rule, because a concurrent run may hold one, so an orphan was invisible. scripts/check-ci-access-windows.mjsmatches eachci-*rule on the site and the SCM site to its run by the id its name ends in, and names any whose run has ended or does not exist. Each orphan comes with the exactaz functionapp config access-restriction removecommand.monitor-functions-registered.ymlruns it every hour, reading twice a minute apart so a run closing its own window is not named, and shows it as one more row: ✅, ❌ or ⚠️.-
The 2026-08 review's T-713 "FIXED" counted only the storage account's rules. The Function App's
ci-*rules it named had no probe until now, and the review carries a dated correction. -
Labs & Architectures: per-lab VPS bootloaders, Linux container sandboxes, Defender WSL container lab, and shared mini landing pages (#1080, #1082, #1083, #1084).
- Shared mini landing page (
#1084):MiniLandingFrame(frontend/src/components/shared/MiniLandingFrame.jsx) extracts the mini-header bar, status pills, metric strip, and section tabs into a reusable shell.ArchitectureDetailTemplatewraps every architecture detail view inMiniLandingFrame, andfrontend/src/data/architectures.jsaddsazureArchitectures(7 Azure architectures led bysecure-2-tier-azureplus all 6 blueprints from/azure/architecturelinked viadetailSlug), completeawsArchitecturesdetail entries, andarchitecturesByProviderso/azure/architectures/:slugand/aws/architectures/:slugrender full interactive detail pages with Mermaid topology, simulator controls, FinOps telemetry, and operational playbooks. - Per-lab VPS bootloaders (
lab-image/bootloaders/):hcw-boot <lab-id>dispatches to isolated per-lab bootloader scripts (terraform-validate-walkthrough.sh,ansible-syntax-check-walkthrough.sh,landing-zone-builder-output.sh,wsl-containers-walkthrough.sh,defender-wsl-plugin-walkthrough.sh, plus the guided Add-On bundle bootloadersmigration-assessment.sh,network-assessment.sh, andcloud-assessment.shusingpython3 -m zipfile -e) baked intodocker.io/hybridcloudworks/hcw-labunder/usr/local/libexec/hcw-boot/.lab-host/coder/templates/hcw-lab/main.tfadds thelab_idparameter and runs/usr/local/libexec/hcw-boot/hcw-boot "$LAB_ID"before starting the Coder agent,lab-host/coder/launcher/launcher.jspassesrich_parameter_values(lab_id) when creating a workspace, andhcw-coder-template-pushaccepts an optional[template-name]argument (defaulting tohcw-lab). - Self-hosted Docker Sandbox CLI & Linux container lab (
#1080,#1082):SandboxSectionand/docker/sandboxesadd a Run in Browser / Self-Hosted VPS toggle alongside Run on Your Machine (Docker Pro/Team local CLI), andwsl-containers-walkthroughin the lab catalogue (frontend/src/data/labs/catalogue.js) adds a live VPS terminal mode (liveEnabled: true) alongside its browser simulator. - Defender for Endpoint WSL 2 Linux plugin container lab (
#1083):defender-wsl-plugin-walkthroughjoins the lab catalogue as a Linux container lab (no Windows VMs) withDefenderWslSimulator(frontend/src/components/labs/DefenderWslSimulator.jsx) and thedefender-wsl-plugin-walkthrough.shVPS bootloader (mdatp health,mdatp connectivity test, andtvd-scanmocks). -
AddOn lab-host pre-wiring:
lab-host/ansible/group_vars/all.ymlpre-wiresnetwork-assessment(127.0.0.1:18082, with always-on guided workspace handoff settingsNA_PUBLIC_BASE_URL,CODER_URL,CODER_TEMPLATE_ID: na-guided-lab,NA_WORKSPACE_TTL_MINUTES: 240) andcloud-assessment(127.0.0.1:18083) withimage_digest: ""(skipped until digest-pinned images are published). -
Ambassador hub: AWS Community Builders, GitKraken's real quarterly rule, and what keeps a membership active (owner request 2026-10-11).
- AWS Community Builders joins the seeded catalogue, from its program
page:
- the waitlist link;
- open to every AWS builder, with a limited intake each year;
- the yearly January cycle, noted as "edit to match".
- GitKraken Ambassador takes its rule from the program's quarterly
check-in of 2026-10-08:
- Two or more content pieces a quarter, each logged on the program's contribution tracker, on that quarter's tab.
- Its criteria are the check-in's focus: review sites, Reddit, articles that use data, shared GitKraken content, and videos on Merge Conflicts, Dev AI Use and Commit Graph.
- The stored program updates itself on the next read, field by field,
wherever it still holds the first seed's guess
(
SUPERSEDED_SEED_VALUES). A field the owner edited stays as it is.
- Upkeep (
upkeep): a program can say what keeps a held membership active, as a minimum of evidence filed under it in every calendar quarter or year. A card whose membership is Active shows this period's count and its due date, and Details shows this period and the last. Only evidence filed under the program counts; general evidence says nothing about it. -
Contribution tracker (
contributionTrackerUrl): the link to where a program wants contributions logged. It is set on Settings and stored with the program only. No seed carries one, and a test holds the seed file free of shared-document links: such a link is private to a program's members, and this repository is public. -
Alerting: one script runs each PLAT-4 fire test and proves the alert fired from Azure Monitor, not the inbox (#964). "Making each one fire once" in
docs/runbooks/alerting-and-support.mdwas ten long manual blocks whose proof was a mail arriving.scripts/alerts/Invoke-AlertFireTest.ps1 -Test <name>runs one block as written and puts it back. - Same stop conditions, refusing and changing nothing when one fails; the
same record files in the home folder (
plat4-rbac-test.json,plat4-roledef-test.json,plat4-poison-test.json,plat4-swa-test.json,plat4-timer-test.json), written before the change they describe, withOut-File -NoClobberwhere the runbook makes the record the lock. Every change is put back in afinally, so a failure or Ctrl+C part-way still restores, and a record is removed only once the runbook's own read shows nothing left; otherwise it is kept and the run exits 3.-Recoverdoes a test's recovery from its record;-WhatIfruns the reads and prints every change as the one line it would run. - The proof is Alerts - Get All (
Microsoft.AlertsManagement/alerts, api-version 2019-03-01, the version Microsoft's reference documents), filtered to the rule's id over the last hour and read once a minute.PASS <rule> fired at <time>needs an alert that started after the test did;alert-timer-overdue's must also namehealthPulsein its context. Anything else isFAIL <rule>: no alert within <n> minutesand exit 2. The run ends with the Fire record rows to paste. - Checks the runbook leaves implicit, each a read: az's default
subscription is
sub-app-site-prod-cus; every rule exists before anything changes;swa-bandwidthsaves nothing unless the threshold reads 2666666666;lab-agent-offlinerefuses unlessFEATURE_FLAG_CHECK_AGENT_HEALTHistrue. -
scripts/alerts/Invoke-AlertFireTest.Tests.ps1(Pester 5) tests the parser, every stop condition, the lock,-WhatIf, and each put-back when the action fails, against a mocked az, and runs the whole script against a stand-in az that refuses every write. Repository Policy's Pester step now runsscripts/alerts. The runbook's section opens with a table of the ten run lines and the seven recovery lines. -
The migration add-on moves to v0.3.1. The
migrationrow inlab-host/ansible/group_vars/all.ymlpins tag0.3.1by its image index,sha256:88b8d088…, published by the add-on'sv0.3.1release. The release adopts the upstream explorer 0.3.1 from the first live run with a portal export: the questionnaire keeps focus while typing, the destination region is a select over the Azure regions, and the portal export's RESOURCE LINK column yields resource ids, so no re-export is asked for. Runbook and the pair's architecture page carry the new tag and digest. -
The migration add-on is turned on for the lab host (ADR 0035, #1064). The
migrationrow inlab-host/ansible/group_vars/all.ymlnow carries: image_digestset to v0.3.0's image index,sha256:175622db…. Tag0.3.0points to it, the add-on's publish run recorded it for pinning, and its build-provenance attestation verifies against the add-on's repository.- The
hcw-addon-migrationverification widget's site key, in place of the provider's test key, which the role refuses to deploy.
The image was run read-only, with every capability dropped and the row's
environment, and it answered /api/health 200 ("ok": true, version
0.3.0, verification required). The lab-host README, the labs-host runbook
and the app-to-addon pages record the widget and the digest, and drop their
[VERIFY] and [REVIEW REQUIRED] markers for this row. The next bootstrap
deploys it, once the widget's secret key is in the vault as
vault_addon_migration_turnstile_secret.
- Hybrid Lab: an hourly canary runs one real lab job end to end, and a refused Coder status token is raised (#1009, "PR 2: prevent recurrence", items 2 and 4). The admin Labs probe enqueues and cancels, so it passed with no agent at all while no lab job ran for days; and Coder refused the status token for about 32 hours with only a card saying "unknown".
labCanary(functions/src/lib/labs/canary.js), hourly at 20 past, behindFEATURE_FLAG_LAB_CANARY(LAB_CANARYinenabled_timers, off until the owner arms it): oneshell-echojob with the payloadhcw-canary <job id>, enqueued asenqueueLabJobwrites one, waited for up to 150 s, passed only when the agent echoes the payload exactly, then deleted under its ETag. Nothing is enqueued when no active agent registered forshell-echois online. A job still queued at the deadline is cancelled and deleted; one still claimed is left to finish, and the next run deletes it (or withdraws it past the claim lease) and enqueues nothing new meanwhile. Each run is recorded with its claim and run times inadmin_config/lab_canary, where the job's id is reserved under the record's ETag before the job is created, so a run that dies or cannot write its record never loses a job to the next. The record also names its owning run (activeRun), so an overlapping run touches nothing and a run's last write never clears another's reservation.- Every read of Coder with
CODER-STATUS-TOKENrecords Coder's answer inadmin_config/coder_status_token(recordTokenAnswer, ETag-guarded, an unchanged answer at most hourly, one outage keeping its start), kept per operation: the labs status read and the Integrations card's read of the token's own record, so a refusal is cleared only by the same read succeeding. A 401 writes a warning startingcoder-status: Coder refused CODER_STATUS_TOKEN (401)for a log alert to match; a 403 now says it lacks a read scope rather than "expired or revoked".checkAgentHealth, which is armed, runscheckTokenIfDueand so asks Coder directly once an hour, never through the anonymous minute cache, whether or not the canary is armed, so warm-cache traffic cannot hide a refusal; visitors' live reads record their answers too. - Two more Health Hub probes, recorded by the pulse and shown by the Labs
snapshot: Lab job canary (
lab-canary; offline when nothing ran the job, critical when it ran and failed, unknown once the canary stops) and Coder status token (coder-token; critical from the first refusal until Coder accepts it again, unknown after a day without a read). -
The timer is in
local.timer_catalogue, theenabled_timersvalidation,function-inventory.jsonandalert-timer-overdue's schedule map; the plan check declares the new flag, its arming, and the rule's query with the extra row (rendered withterraform consoleand checked against the map by a test). No alert rule is added; the alerting runbook lists the two queries a follow-up rule would use. -
Hybrid Lab: the site raises a host that lags
main, and a Coder template the host did not publish (#1009, "PR 2: prevent recurrence"; the heartbeat half of LAB-4, #950). On 2026-10-08 the host was six merged pull requests behind and nothing said so. It raises; nothing here re-runs the playbook. - The host records what it converged from.
site.yml's last task, after every role, handler and privilege check, writes/etc/hcw/applied-commit.json(root:root0644; the commit, its committer date, the time the run finished;lab_host_applied_commit_file). A failed run leaves the previous record and a--checkrun writes nothing. - The agent reads it on every heartbeat (
vps-agent/lib/applied-commit.js) and sends it asapplied;heartbeatAgentchecks it (a full sha, two ISO dates, none more than ten minutes ahead of the site's clock) and stores it on the agent's registry document only when it changed. checkAgentHealthreadsmain's commits underlab-host/andvps-agent/from GitHub's public REST API once an hour, from the oldest commit an active host reports, intoadmin_config/lab_drift(functions/src/lib/labs/drift.js), through the Drafts import's pinned, redirect-refusing, size-capped fetch. No token and no new secret: the repository is public. The Functions app's own build commit was not used, because deploys are by hand and say nothing aboutmain. A failed read keeps the last list and records why (RATE_LIMITED,TIMEOUT, …), and the run's marks and messages come first and never wait on it.- Two Health Hub probes, recorded by the health pulse every five minutes
and shown by the Labs snapshot, judged by the same functions
(
functions/src/lib/labs/lab-checks.js): Lab host runs main (lab-drift) is critical once a host-path commit has waited more than 24 hours unapplied, or when an active host has never reported a commit; Coder template published (coder-template) is critical when the template Coder serves is not the converged commit's, from two digests the host's daily Coder upkeep report now carries (templateDigest, whatpush-templatelast published;templateSourceDigest, the checkout now), and when the automation never published one. A stale read ofmain, or a report without the digests, is unknown, never healthy. -
The Labs Agents tab shows each host's
commit <8 hex> · converged <time>and the drift verdict, in its colour. -
Eleven alert rules for what the estate collected and nothing paged on (PLAT-4, #964). All route to
ag-plat-prod-cus-01, and all read data that already arrives, so none adds ingestion. alert-timer-overdue: one rule over all 25 timers, one alert per timer through atimerdimension, firing when a timer due at least 45 minutes ago has no successful request row since. The schedules live inlocal.timer_schedules(period and anchor). The newfunctions/src/functions/timer-alert-schedules.test.jsholds that map to the realapp.timer()registrations, to each NCRONTAB and tofunction-inventory.json. More than five overdue at once collapse into one alert, and the rule is quiet while no request telemetry arrives at all, so a capped workspace or a dead host does not send 25 mails and 25 texts.alert-lab-agent-offline:checkAgentHealth's decision to tell the owner an agent has been offline five minutes, so the owner hears by mail and SMS when Telegram is what failed. The nightly reboot does not fire it.alert-jobs-poison: a successfulPutMessagetoplatform-jobs-poison, from the storage queue logs already shipped.alert-swa-bandwidth: a metric alert on the Static Web App'sBytesSentover a day, at a pace that would use 80% of the Free plan's 100 GB month.alert-kv-errorsandalert-kv-data-write: Key Vault requests answering 400 or above (past the routine 401 challenge and unseeded-secret 404s), and data-plane secret or key writes by anyone but the Function App, on both vaults.alert-kv-config-write,alert-rbac-write-appandalert-rbac-write-mgmt: Activity Log alerts, which are free and need no export, for Resource Manager writes to either vault and role assignment or definition changes in both subscriptions.alert-lab-vault-unwrap: the seal key wrapped or unwrapped from an address the lab host has not heartbeated from, read from the host's ownHeartbeatrows rather than copied from thehcw-labworkspace.alert-lab-ssh-burst: failed SSH lines in 15 minutes at least 50 and four times the day's quarter-hour average.-
Each create is declared in
scripts/assert-expected-plan.mjs.kql-alert-columns.test.mjsnow reads every.tffile, notobservability.tfalone.terraform-identity-grants.test.mjsclassifies the Activity Log alert and admits a whole subscription in itsscopesonly (read, not written; ADR 0005's step-two table records it). Every query was parsed and type-checked with Microsoft's Kusto.Language parser. Estimated cost is about USD 4.70 a month. The alerting runbook's new The PLAT-4 rules section has the operator table, three pre-apply checks, a fire test for each rule and a fire record. A daily digest of denials needs code and is left as a follow-up. -
ADR 0035 accepted (2026-10-10, #1044). The owner confirmed decision 8 (AddOn containers run beside Coder on the lab host, amending ADR 0032) and decision 9 (
allow-downloadson AddOn panes): the migration catalogue row now grantsnavigateanddownloads, so its sandbox isallow-scripts allow-same-origin allow-forms allow-downloadsand the report bundle saves from inside the frame; the accepted-risk row inTODO.mdapplies. The standard, the program package and the decisions index record the confirmation;[REVIEW REQUIRED]remains only on the per-AddOn human-verification widgets, which the owner creates. - AddOn panes: independently built tools in sandboxed panes on their own
lab names, starting with the Migration Hub (ADR 0035; the Phase 6
website pull request of the App-to-AddOn program). The site gains a
catalogue of tool add-ons (
frontend/src/data/addons/catalogue.js: the migration rowavailable, the network and cloud assessmentscomingsince 2026-10-10), a generic pane page at/tools/<id>(pages/tools/AddOnPanePage.jsx: the status gate, the sandbox andallowlists derived from the row's capabilities, thehcw-addonmessage filter with its four states, an allow-listednavigate, a 30-second watchdog, and one sentence for every way the tool can be missing), routes, pre-render entries and the Tools menu derived from it, and one exact origin per available row in the CSP'sframe-src./tools/migrationgoes from a placeholder to the pane. The Function App gainsGET /api/public/addons/{id}/status(lib/addons/): ids closed in a server-side registry, a 5-second https-only read of the add-on's/api/health, a one-minute cache per id with failures cached, and a six-field projection that never carries the URL; the address is the plain app settingADDON_MIGRATION_URLin Terraform. Admin gains the Tool add-ons group with the Migration add-on card, probe and directory row, and the Credentials register lists the add-on's vault key. The lab host gains the genericaddonsAnsible role (one hardened container pergroup_varsrow by digest on the loopback, a Caddy route per name with the site's sentence at 503, a per-name direct-visit redirect whose directive orderingcaddy adaptchecks in CI, the end-of-run privilege checks extended to each container) and the migration row, with the digest and the widget's site key marked[VERIFY]for the owner. The AddOn integration standard lands indocs/standards/, the program package indocs/architecture/app-to-addon/, and ADR 0035 records the model with two items for the owner's review. Review round 1 (Copilot): the add-on containers run on their own bridge network with inter-container communication off and egress limited inDOCKER-USERto the verification endpoint's published ranges on 443 (also written to ufw'safter.rulesfor the next boot); no volume or mount, compared strictly; a row with no digest answers 503 directly and any container left from an earlier digest is removed; the privilege checks assert the network, the one loopback binding and no mounts, null-safe; the status proxy validates the whole version string, shares one in-flight read per id, and logs by setting name, class and code only (the minute cache's warnings too); the pane focuses its frame on load; the migration row ships withoutdownloadsuntil ADR 0035 decision 9 is confirmed. -
Credentials: one register of every credential, with its age and expiry, on Admin → Integrations → Credentials, and a Telegram reminder for each one only the owner can renew (#1026).
functions/src/lib/credentials/holds a static register of every credential by name and metadata only — where each lives, what uses it, its issuer, whether it renews itself (self,automation,hand, ornonefor an identifier) and, where a rule exists, its lifetime: Key Vault (built fromsecret-catalog.jsso the two cannot drift, whichregister.test.jsholds), the three MCP OAuth connections inmcp_servers, the GitHub Actions and Agents secrets, the OIDC federations, the HCP Terraform variables ofhcw/hcw-azureandhcw/hcw-lab, the Static Web Apps deployment token, the Entra and Docker identities, and the lab host's vault keys, files and the owner's password-manager entries.GET /api/cms/credentials(super_admin) merges in the live dates — the Keys tab'slastWriteAt, the lab host's Coder token report, each MCP connection's state, read as a Cosmos projection so no token field is ever fetched — and answers each credential's age, expiry (reported, or the last rotation plus its rule for a hand-renewed one), and a state: ok, due soon, overdue (red), or unknown. A source that cannot be read, or is present but malformed, is never read as empty: the credentials that depend on it show unknown, with no expiry or reminder.PUT /api/cms/credentialsrecords, under the document's ETag, when a hand-renewed credential the site cannot read was last rotated (admin_config/credential_register, a date per credential). Every hand credential with a rotation rule gets the reminder rowcredential-<id>on the existing Reminders sheet, said by the existingsendReminderstimer: the Anthropic key, the Telegram bot token, both GitHub App private keys and the Coder GitHub OAuth secret (every 180 days, the owner's rule of 2026-10-09), the Static Web Apps token's 90-day reset and the two 730-day lab host certificates. With a known date it is "Rotate …" at the due date; without one it is "Record when … was last rotated", due the day it was added, until the date is recorded. The sync owns only those rows: the owner's own reminders pass through untouched, the timer's stamps are kept while a row's due date and title stand and reset when either changes, and every attempt re-reads the sheet and then the dates and writes under the sheet's ETag, so a rotation recorded mid-sync is never overwritten. Recording the rotation is the only way a register row's cycle ends: the sync reopens one found marked done, the Platform Settings save (editor) carries everycredential-row through exactly as stored and cannot add, change or remove one, and the Reminders sheet shows them read-only, "Managed by Integrations → Credentials". It runs when a rotation is recorded, from the tab's Update reminders (POST /api/cms/credentials/reminders), and at the start of everysendRemindersrun. No value is read, stored or returned anywhere.sendRemindersalso binds each Telegram stamp to the cycle it was said for, the row's due date and title: after an ETag conflict it re-applies a stamp only to a row still in that cycle, so a reminder re-dated or renamed meanwhile is said afresh. This applies to every reminder, and nothing changes when there is no conflict. A Platform Settings save of the sheet is written under the ETag of the read it merged from (412 or 409 re-reads, three attempts, then 409), so a sync or a stamp that lands between the read and the write is never written over. A Keys-tab write syncs the credential reminders at once, so a rotation moves its reminder without waiting for the timer, and the register copies onlylastWriteAtandrotatedOnout of the records it reads. An editor's save of the sheet may hold at most 180 of the owner's own reminders: 20 rows stay free for the register, so its warnings can always be installed. -
Lab host: Coder automation. The host renews the site's Coder status token and publishes the
hcw-labtemplate on its own, with one rotation credential the owner seeds once (owner approval 2026-10-08).hcw-coder-automation-seedtakes the owner's short-lived token on stdin and creates the rotation credential: an unscoped, year-long token ofhcw-status, which must be a Template Admin and must not be an Owner, so it cannot manage users or act as anyone else. It is a root-only file,/etc/hcw/coder/automation/rotation-token, not a vault secret, because nothing on the host can read HashiCorp Vault unattended (no auth method, sealed after a reboot under Shamir keys) and the Ansible vault's password is a root-only file beside it. The dailyhcw-coder-automation.timerrenews the site's status token when the one the site holds expires within 30 days or is over 60 days old: a 90-day token with exactlytemplate:read,workspace:read,api_key:readanduser:read(the last so the token can read its own expiry, which the site checks), checked against Coder, then handed to the site through the lab agent's CLI (vps-agent/bin/report-coder-automation.js, run withsystemd-runas the agent's user with itsEnvironmentFile), live only once the CLI answersstored: true. It deleteshcw-status's older status tokens, keeping the newest two, the one before the live one and any younger than 48 hours, renews the rotation credential under 60 days, and reportscheckedAt, both expiries, the rotation and the template's publish, and a content-freelastError, every run; a failure fails the unit, which thehcw-unit-failed@notifier turns into the lab alert. Everybootstrap.shrun publishes the template with the credential when a filehcw-coder-template-pushcopies, or the default autostop, changed since its last publish, beforelab_imagesremoves the old version's images; with no credential it says how to seed one and carries on. Coder v2.38's token API was read from its docs and source:lifetimeis a Go duration in nanoseconds, no scope meanscoder:all, and a Template Admin's tokens may live a year where an Owner's stop at 168 hours. The owner's lines, what success looks like and how to rotate by hand are indocs/runbooks/labs-host.md, "Automatic renewal"; the design is inlab-host/ansible/roles/coder/README.md, "Coder automation". Tests:lab-host/ansible/roles/coder/tests/hcw-coder-automation.test.py, run in theansible-lint (lab-host)job against a fake Coder API, a fakesystemd-runand a fake site CLI, with no token in any output, state or report. The seed keeps a stored credential only whilehcw-statusis still a Template Admin and not an Owner, and every run refuses one that is not a Template Admin; a new credential that fails before it is stored, and that Coder will not delete, is recorded by id and deleted by every later run until it is gone (review of #1035). A replaced credential is listed the same way, before the new one is written, so an interruption cannot leave it usable and forgotten;hcw-coder-automation revoketurns the automation off by deleting the credential in Coder before the file, and comes before any revert of this code; a suspected leak is contained on the account, by deletinghcw-status, which deletes every API key it has, successors included (CodeRabbit, #1035). - Coder automation, the site's half: the lab host can hand the site a
renewed Coder status token through the lab agent, and the site stores it
only after Coder vouches for it as a working, read-only key of the status
user; the Hybrid Lab card shows what the host last reported (owner
approval, 2026-10-08).
Until now
CODER-STATUS-TOKENwas renewed by hand once a year, minted in a pane and pasted on the Keys tab, because the host cannot reach Key Vault and must not. A fourth agent route,POST /api/agent/reportCoderAutomation, guarded byrequireAgentexactly as the heartbeat is, takes{ agentId, statusToken?, report { checkedAt, statusTokenExpiresAt?, statusTokenRotatedAt?, rotationTokenExpiresAt?, templatePushedAt?, templateVersion?, lastError? } }and refuses anything else with a 400 and a sentence before any read (functions/src/lib/labs/coder-automation.js, composed inlib/lab-agent.js). A token must have Coder's key shape, measured against Coder v2.38's source (a 10-character id, a dash, a 22-character secret), and must pass three reads made with it throughcoder-status.js's guarded GET, each answering exactly 200 (verifyStatusToken): the running-workspaces read gives an integer count,GET /api/v2/users/menameshcw-status(CODER_STATUS_USERmay rename it), and the token's own key record liststemplate:read,workspace:readandapi_key:read, withuser:readthe one more it may carry. An unscoped key (coder:all, orallin the deprecatedscope), a key with any other scope, and another user's key are refused, so a working Template Admin key cannot become the read-only status token. Only then is it written bywriteCatalogSecret, the Keys tab's own writer factored out ofadmin-secrets.js, so the Keys row goes amber and then green as for a paste and there is no second vault client. The writer never throws past the vault write and saysvaultWritten, so a failure after it is answered 200{ ok: true, stored: true }with the failure aslastError, and the host does not mint again. Thesecret_statedocument the Keys tab, the agent and every key verdict share is now written one secret's record at a time under its ETag (updateSecretRecord), so a verdict landing during a renewal is no longer lost. Onecoder_status_token_rotatedaudit row, never the value. A token that fails a check is 422 with a sentence naming the check, and a vault refusal 502; the report is recorded either way in oneadmin_config/coder_automationdocument, merged under its ETag (event dates kept when a report omits them,lastErrorcleared by a clean check, anything shaped like a Coder key in it removed). AcheckedAt,statusTokenRotatedAtortemplatePushedAtmore than ten minutes ahead of the site's clock is a 400, and the card measures silence from the site's ownreportedAt, so a host clock in the future cannot hide a host that has stopped. Warning and error lines name a status, never the token, the agent or a document id.GET /api/cms/labs/coder-automation(editor) serves the document, and the Hybrid Lab card on the Services tab now groups its keys: Coder's two values together with Automatic renewal (status token renewed on, rotation credential expires on, template last published, last check, andlastErroramber or red bylib/status.js), Turnstile's apart. Before the first report it readsAutomatic renewal not set up yet, and the manual paste on the Keys tab is unchanged. On the host side,vps-agent/bin/report-coder-automation.jsreads one JSON object on stdin, addsagentId, posts it with the agent's API client, prints{"ok":true,"stored":<bool>}, and on failure prints the error's class alone; the agent's configuration moved tovps-agent/lib/config.jsso the agent and the one-shot read it one way.lab-host/README.md, "The status token for the site", gains a paragraph naming the three checks, saying the host should mint with--scope user:readas well (from Coder v2.38's source: both identity reads load the caller's user through its authorizing store; not yet measured on the lab's Coder), and pointing to the host's setup. Tests:functions/src/lib/labs/coder-automation.test.js,coder-status.test.js,admin-secrets.test.js,key-verdict.test.js,lab-agent.test.js;frontend/src/components/admin/integrations/coderAutomationView.test.js,IntegrationsServices.test.jsx,serviceRegistry.test.js;vps-agent/lib/report-coder-automation.test.js,config.test.js,api.test.js. -
CodeRabbit is configured for this repository, for its free open-source plan. It reviews nothing until the CodeRabbit GitHub App is installed on the repository, an owner step.
.coderabbit.yamlat the root (owner request, 2026-10-08) enables no paid add-on, and every key and value was checked against CodeRabbit'sschema.v2.json. It reviews drafts too, with thechillprofile and no request-changes gate, and it skips lockfiles, build output,CHANGELOG.mdanddocs/history/. It reads the repository's own review checklist in.github/skills/code-review/as its code guidelines, with short path instructions for the functions, infra, lab-host, public-page and docs rules. On the open-source plan a public repository with few stars is reviewed when asked: comment@coderabbitai reviewon the pull request.scripts/validate-repository-structure.ps1allows the file at the root. -
Publer MCP:
PUBLER_API_KEYusable as an MCP key, bound tomcp.publer.comonly, a server-side MCP tool allowlist that keeps Publer's write tools out of reach (#995), a disabledPubler MCPseed entry, and a Publer runbook with the 2026-10-07 readiness check (nothing published).functions/src/lib/ai/mcp-policy.jsrefusedPUBLER_API_KEYas an MCP key name, so Publer's MCP server could not be registered with the key the Social Hub already holds. The name is now on the allowlist withmcp.publer.comas its one host (app.publer.com, the REST host, stayspublerProxy's and is refused for an MCP server), and a URL on that host may carry no query string, because Publer's settings page hands out its server URL with the key as?api_key=, which would otherwise sit in Cosmos, in every read of the server list and in the audit row. No header code: Publer's server accepts the resolver's plainBearer, and a Sync through the repository's own path reached its 47 tools. The AI Engine seed gainspubler-mcp, switched off, written the first time a super_admin opens the MCP Servers tab, with a setup page.docs/runbooks/publer.mdrecords the transport, the auth shape, which tools read and which write, and the owner's read-only verification (get_publer_user). Tool allowlist (#995): an MCP server may carryallowedTools, andcallMcpTool(somcpProxyand the platform jobs) refuses any other tool with 403, naming the tool and the server, before any upstream request and whatever the caller's role. A server usingPUBLER_API_KEYmust carry a non-empty list: saving one without it is refused and a stored one without it refuses every call. The seed's list is Publer's 27 read and 7 session tools, never the 13 that create, schedule, publish, edit, delete or upload. WritingallowedTools, and switching aPUBLER_API_KEYserver on or off, needsuper_admin; a PUT that omits the list keeps the stored one; Sync never touches it; theai_config_updatedaudit row records it before and after. Firecrawl, Replicate, the VPS token and keyless servers carry no list and still accept any tool name, as before. The MCP card reads "34 of 47 tools allowed". Tests:functions/src/lib/ai/mcp.test.js,functions/src/lib/admin-integrations.test.js, andfrontend/src/lib/aiEngine.test.js(the seed against the API policy). -
Platform Settings → Reminders shows the Telegram bot's QR code, for pairing a phone with the bot again (owner, 2026-10-07). The owner's QR artwork for
@agenticarchitectbotnow sits at the top of the Reminders card, at a fixed 220 px wide on a laptop and 60 % of the screen width on a phone, on a white panel so the dark artwork reads in both themes, with the handle linked tohttps://t.me/agenticarchitectbotbeneath it and an Open full size icon on its top-right corner. The artwork is AI-generated (its C2PA manifest names the image generator) and no decoder read its embossed modules, so it is not verified to scan; the handle link is the fallback. A second, plain black-on-white code that #994 placed beneath it was removed the same day at the owner's request, and on a laptop the steps beside the artwork now start level with its top and end, with the Test Telegram frame, on the handle's line. Three steps cover a phone that loses the bot (scan the code or search the handle, press Start or Unblock, press Test Telegram and watch for the message on that phone), with the existing Test Telegram button inside the same panel. The note under them says what Sent proves: Telegram accepted a message for the chat id inTELEGRAM_CHAT_ID, not that this phone holds that chat. When Sent shows and nothing arrives, check which account the phone is signed in to first (the same account keeps the same chat id on any phone), and change the stored id on Integrations → Keys only if the account itself changed. The artwork is imported fromfrontend/src/assets/admin/as delivered (1,907,187 bytes) and fingerprinted into/assets; it loads only when that page is open.TelegramReconnectPanel.jsx, with tests beside it. -
Governance remainders of the 2026-10-06 estate review: the Terraform run identity's Contributor scoped to its resource groups, code owners on the auth and lab paths, a 100-day restore-drill check, and Arc guest configuration off (findings SEC-1, SEC-3, PLAT-1, LAB-6; #971, #972, #961, #952). SEC-1:
scripts/bootstrap-terraform-oidc.ps1now grantsid-plat-terraform-prod-cus-01Contributor on each of the nine resource groupsinfra/declares and a custom role,HCW Terraform Subscription Scope, for the only thingsinfra/does at subscription scope (create and update those groups, the two subscription budgets, provider registration), with no resource-group delete, since at subscription scope that verb deletes any group and its contents. Both lists live in the newscripts/terraform-identity-grants.json; the custom role is written there with its assignable scopes filled at run time, because the Management and Connectivity subscription ids are not published. Step one of two: subscription Contributor stays until a separate later change removes it, after a plan and apply have run on the narrow grants, and the file comments say why the two are apart. The RBAC Administrator condition (Set-TerraformRbacCondition.ps1) now also refuses Contributor, so the scoping cannot be undone by one role assignment, and its recovery path keys on the custom role as well as subscription Contributor. Newscripts/terraform-identity-grants.test.mjskeeps the JSON's groups equal to theazurerm_resource_groupblocks, its register actions equal tovar.azure_resource_providers, and fails ifinfra/ever assigns a role the condition refuses. ADR 0005 gains a dated amendment: the condition stays an operator script (held in Terraform it would be maintained by the identity it constrains), the two-step scoping, and the read-scoped team token recorded as an owner decision (HCP Terraform Teams is not entitled);TFC_AZURE_PLAN_CLIENT_IDis noted as the separate, Teams-free route to plan-only Azure credentials, not built. SEC-3:.github/CODEOWNERSadds/infra-lab/, the identity scripts,functions/src/lib/auth/,admin-identity.jsand its HTTP route,useAdminAuth.js,msalConfig.js,/lab-host/and/vps-agent/; the ruleset rule and 2FA stay the owner's, and the file says that requiring code-owner review blocks every pull request until a second reviewer exists. PLAT-1: newscripts/check-drill-age.mjsreads the Drills table indocs/runbooks/cosmos-restore.md(row format now defined there: date, RTO measured, RPO measured, who, applied runs, documents, notes), exits 1 when the newest dated row is more than 100 days old or there is none and 2 when a Date cell is not a real past date or a dated row leaves any cell but Notes empty (a date alone is not a drill), and new.github/workflows/check-drill-age.ymlruns it every Monday and opens or comments on one issue. The table is empty, so its first run fails on purpose. LAB-6: thearcrole setsguestconfiguration.enabled falsewith the same read-compare-set as the allowlist and incoming connections (owner decision 2026-10-07: Microsoft's locked-down-machine guidance, and no machine-configuration charge); ADR 0032 records it, and that the audit-only Linux baseline assignment then has no agent to evaluate it, which is left to the owner on #952. Owner steps: re-run the bootstrap script, then a plan-and-apply inhcw-azureshowing only the permanent diff; the three GitHub settings pages;bootstrap.shon the lab host andazcmagent config list; the drill. -
The admin journey, signed in, in a browser on every pull request, with no tenant and no secret (estate review 2026-10-06, findings QA-1, AP-F1, AP-F2).
e2e/fixtures/entra.jsis a credential-free identity: the production bundle, built with placeholder settings (GUIDs no directory issued, anapi://scope that does not exist, the same-origin/apibase; one list ine2e/fixtures/stub-build-env.js, set only by thee2ejob's Build step and the local Playwright web server), runs MSAL's real authorization-code redirect, andpage.route()answers login.microsoftonline.com (metadata,/authorizeechoing MSAL's state,/tokencarrying its nonce) and/api. Nothing in the application knows it is under test, and no test switch was added to it. It fails closed: a request to any third host is aborted and fails the test (the icon font is answered locally), a tenant or client other than the placeholder is refused, and the local web server is never reused, so a bundle built with real settings, from a local.envsay, fails the run instead of sending the stub's bearer to a real API.e2e/admin-authenticated.spec.jsasserts that the callback lands on the route the person asked for, that a registry refusal shows "Access Denied" and nothing of the admin, that aninsufficient_scope401 shows the configuration card with the API's description and does not retry, that aninvalid_token401 leads to exactly one re-authentication (no account picker) and then the honest card, still once after a reload, and on a Pixel 5 at/admin,/admin/queue,/admin/platformand/admin/healththat the content column is at least 340 px with the drawer closed, the drawer opens from the visible menu button and closes on Escape, and nothing scrolls sideways. Each test names the product change that makes it fail; eleven such changes were made, rebuilt and run, and each failed its test. The twelfth, dropping the callback page's pathname check before its/adminfallback, did not: MSAL 5 returns to the start page with a full navigation, so that line never runs in this flow, and the spec says so rather than claiming it. Thee2ejob runs it on desktop Chrome and the Pixel 5, still not a required check; the three specs together took 100 s on one worker locally. The signed-out spec's/admin/platform-settingswas not a route and is now/admin/platform. Owner step: promoting the job to a required check stays the two-weeks green decision, about 2026-10-20. -
The collapsed admin rail shows each item's label on hover and on focus (AP-F2). Collapsed, the rail was icons with the label in a
titleattribute, which touch never shows and a keyboard never triggers. Each item, Back to Site and Sign Out now show a visible label beside the icon on hover and on focus, carrying the text the link's name starts with; Escape dismisses it. It is fixed-positioned, because the nav is a scroll container and would clip anything placed against the 64 px rail; the browser test checks it is painted outside the rail. -
Lab pins move themselves by pull request, each with its publisher's checksum; the lab images rebuild weekly; the host reports held packages with an upgrade waiting; runtime advisories have stated response times (estate review 2026-10-06, finding LAB-3, the remainder; #949). The weekly check from #899 reported and bumped nothing. Now
scripts/lab-pins-upstream.mjs --bump <set>makes the edits, and only with the checksum or digest read from the publisher beside each version (newscripts/lib/lab-pin-bumps.mjs): a Docker or NodeSource package's SHA256 from the Packages index whose own hash InRelease lists, an image's index digest from the registry (bytes hashed, Docker-Content-Digest agreed) and the Docker Hub API, HashiCorp's SHA256SUMS, node_exporter's sha256sums.txt, and the Go checksum database for the Caddy module. A pin the check calls current is re-read too when its publisher can rebuild a release under the same name (an image tag re-pushed on a patched base, such aspostgres:18.6that day, or a new Debian revision), and its digest or revision moves. A pin with no verifiable checksum is not edited; it is listed in the weekly issue.--dry-runprints the planned edits.lab-supply-chain.ymlgains aproposejob (read-only) and apull-requestjob that keeps one pull request per pin set open on a fixed branch (chore/lab-pins-host,chore/lab-pins-image-base), replacing its commit and body while it is open and never overwriting a commit someone else added (scripts/open-lab-pin-pr.mjs), with the GitHub App token the other scheduled pull requests use, so the required checks run and no workflow holdscontents: write. The lab image base moves within its release line (python:3.14-slim-trixietoday, so 3.14.7 to 3.14.8) withBASE_PYTHON_VERSIONand every FROM.publish-lab-image.ymlgains a Tuesday 05:20 UTC rebuild that publishes only when thefullimage's Debian packages moved (tagged<sha>-rebuilt-<date>), and after every publish achore/lab-pins-image-digestspull request pins the new digests invps-agent/lib/capabilities.jsand the Coder template, each read again from GHCR first;capabilities.jsleaves the push filter so that merge does not republish. Its header says what a rebuild changes on its own (the Debian packages offull) and what it cannot (the base, its Python, every tool inversions.env), and the Building images guide on the site says the same. Thehardeningrole installshcw-held-upgradable.timer, daily at 07:15: onedaemon.warningline taggedhcw-held-upgradableper held package apt could upgrade, which the Arc data collection rule ships, and anoticeline when none, with its own test in theansible-lint (lab-host)job.docs/runbooks/labs-host.md"Runtime advisories" states the response times for a runc, containerd or Docker Engine fix (48 hours for a critical or an exploited escape, 7 days for high, the next weekly bump otherwise), what runs on its own and the owner's commands. The checker also watches buildx and compose. An alert on the new syslog line is PLAT-4's work. -
Admin Labs → Coder tab: Coder's own dashboard, framed (owner, 2026-10-07). Coder's pages open only inside the site (the lab host's panes-only rule), and since #925 the lab panes never show one, so the operator had no way to Coder's Tokens page, where the status-token renewal starts. The tab frames the dashboard, signed in as the operator, with Workspaces, Templates, Your tokens, Users and the audit log one click away, a Reload, and the lab pane's GitHub sign-in link for a signed-out frame. Same sandbox and permissions as the lab pane; nothing here calls Coder's API, the frame does, as Coder's own front end.
-
The lab launcher starts a stopped workspace itself (owner, 2026-10-07; #911). "The lab was enabled but I still needed to start it." A workspace whose last build stopped or was cancelled is now started with the call Coder's own Start button makes, with the CSRF token read from Coder's own page the way Coder's front end does, and the visit waits for the build as for any other start; an outdated workspace is started on its template's active version, so a template change reaches every lab on its next open. A failed build is not started blind, since its page carries the reason and Retry. When Coder refuses, or its page carries no token, the launcher frames the workspace page where Start is, as before: a Coder change here costs a click, never the lab. One attempt per visit.
-
Integrations directory: a configuration-driven tab on the admin Integrations page (owner brief 2026-10-06, slice 3; #919). One card per external service — its Powered-by badge, category, a one-line summary, what it powers here, where it is used, connection status from the same key lights and test verdicts the Overview reads, Docs and Official site links, and Set up, which opens the service on the Services tab. Search, category and connection filters, alphabetical. The rows are
frontend/src/config/integrationsDirectory.jsover the service registry and the brand registry; tests hold the three to each other, so a service cannot be missing from the directory. Admin-only, as every vendor-naming surface is. -
One "Powered by" badge for every third-party service in the admin (owner brief 2026-10-06, slice 2; #918).
IntegrationBadgerenders "Powered by" in the platform's colour with a medallion, from one registry of the platforms the site actually uses, keyed by the Integrations page's service ids; an unknown id renders nothing, so the badge can sit beside any service. Placed on every service card on Integrations (the two composite cards, cloud pricing and the Hybrid Lab, have none by decision), every AI provider card, the podcast voice (ElevenLabs), social autoposting (Publer), newsletter settings (Resend) and the Reminders card (Telegram, which also says "Delivered by Telegram" in the form). Logos are a licensing decision per platform and are not shipped; the medallion is the initial in the brand colour until one is recorded. The label is theme foreground, since brand colours fail WCAG AA at badge sizes. Admin surfaces only: the 2026-09-28 rule keeps vendors off public pages. -
Reminders: edit in place, and a Test Telegram button (owner brief 2026-10-06, slice 1; #917). Every reminder in the pane has an Edit action that opens it into the same form the New reminder uses — title, date, days before, link, notes, and done as the status — saving through the same PUT and updating the pane in place; one delivery method exists, so the form says "Delivered by Telegram" rather than offering a choice of one. At the top of the card, Test Telegram sends one message through the production notifier (new editor route
POST cms/platform-settings/reminders/test) and shows sent, cooldown, or Telegram's own refusal with its status — 403 is a blocked bot, which is what silenced every notification the day this was asked for. -
A refused Telegram delivery now pages the owner by SMS and mail (2026-10-06). The notifier is best-effort by design, so when the owner's account had the bot blocked, Telegram answered 403 to every send for the rest of the day and the only record was a trace line nobody reads; the lab-agent-offline message was among the losses.
alert-telegram-deliveryis a stateful log alert on the Application Insights component: one[notify] Telegram API errorornotifyTelegram failedline in an hour fires it through the action group, which is not Telegram. Owner step: confirm thehcw-azurerun (1 to add beside the permanent diff; declared for the plan check). -
Reminders on Telegram: a sheet under Platform Settings and a daily check (owner, 2026-10-06). Dated things not to forget — a token that expires, a re-verification, a renewal — had been living in session notes. Platform Settings → Reminders now holds them as one
admin_configdocument (reminders, the seventh platform setting, with Change history like the rest), and the newsendReminderstimer (13:00 UTC, flagSEND_REMINDERS) says each one on Telegram once within its days-before window, once on the day, and weekly while overdue, until it is marked done. A stage is stamped only when the message was sent, so an unconfigured bot means a retry tomorrow, not a reminder counted as said. Same evening, on the owner's first look: the tab is a form on top that adds one reminder and clears when the save took, and a pane below listing every reminder with its details, a Done box and a red circle X that cancels it after one inline confirmation; nothing is edited in place. Owner steps: addSEND_REMINDERStoenabled_timerson thehcw-azureworkspace and confirm the run. The plan reads 3 to add, 1 to change, 3 to destroy: the three are the permanent azapi replacements every plan of this root carries, and the one change is the function app'sFEATURE_FLAG_SEND_REMINDERS, declared forassert-expected-plan.mjs, which is the check to read rather than the counts. Then add the first rows at https://hybridcloudworks.com/admin/platform?tab=reminders. - The owner hears on Telegram when a lab agent stops cleanly (LAB-2
follow-up, 2026-10-06).
systemctl stopsends SIGTERM and the agent heartbeatsofflineitself, so the health timer never had a stale agent to mark and its message never fired; the owner's first test produced no message for exactly this reason. The heartbeat now sends the same message on that transition, with the same source, so a stop and the timer's own mark are one message within the cooldown. -
Forge Studio Queue: one document per entry, so a favourites export fits, and a remove on every row (owner, 2026-10-06). The first cut kept the whole queue in one
admin_configdocument capped at 200 entries, and the owner's first real import was a browser favourites export of several hundred links. Entries are now documents of their own (docType forge_queue_item, the same partition), the caps are 2,000 URLs per add and 5,000 in the queue, and every edit, Save and job outcome is an independent ETag-guarded write — no byte budget, no bulk-edit race with a finishing job. An entry's id is its URL's hash, so the URL is the identity and two adds (or an add and the migration) meeting on one URL is a 409 read as skipped; a Save claims the entry before writing its job, so two Saves on one entry start one job. Mutations answer only the entries they changed and the page merges them. The old document is migrated into entries and emptied on the first read after deploy. On the Queue tab every row has its own ×, the header gains Remove selected (n), and the detected list under From a URL shows its first 200 rows and adds every one. -
Forge Studio: From a URL takes many URLs and .html files, and a Forge Studio Queue completes them singly or in bulk (owner request 2026-10-06). The Source URL box accepts one URL or many (one per line, or a block of text with links in it) and an Import .html button reads one or more saved pages for the URLs they link to; every URL found becomes a row with its own Remove, "Add all to the queue" (green) sends the rows to the queue and "Remove all" (red) clears them first. A single URL continues to the Brief as before. The queue is one
admin_config/forge_queuedocument behindGET|POST cms/forge/queue,POST cms/forge/queue/updateandPOST cms/forge/queue/forge(editor; ETag writes, retried, because the job's onComplete writes it too; a byte budget half the Cosmos item limit; the list reconciles forging entries against their job documents, so a lost onComplete never leaves one forging; removing keeps a forging entry). On the new Queue tab the owner selects one entry to complete its "From a URL" fields, or several to set the shared fields once (a field shows a value when every selected entry agrees; blank leaves each as it is); Save applies the values and starts oneforge-from-urljob per entry with the brief on the payload, so the brief lands on the document the job creates whether or not a browser waits, and the entry shows forged (with an Open link) or failed when the job reports back. "Save for later" applies the fields and stops. The Start grid gains a Queue card with the count. -
Blog run: the October 2026 certification calendar for Azure, AWS and Google Cloud (owner request 2026-10-05). Three drafts in
docs/contentfor Admin → Drafts "Import from docs/content", each built only from vendor sentences read during the 2026-10-05 catalogue re-read: Azure (AI-500 out of beta, PL-400 → AB-400 on October 16, MS-102's last eight weeks, the DP-420 rename, retired exams and their successors), AWS (the November version bumps and AWS's 2026-10-02 correction of the SAP/DVA last days, the two open betas, ANS-C01, the language retirements) and Google Cloud (Agentic Architect GA registration on November 2 and why that is not an exam day, the PMLE move away from Vertex AI).drafts-handlers.test.jsnow expects fourteen importable files. -
Learn catalogues: the 2026-10-05 re-read of Azure, AWS and GCP against their vendor pages (owner request 2026-10-05). Every row of the three catalogues was read on the day and
DATA_AS_OFmoves to 2026-10-05 on all three. Azure: AI-500 is out of beta (active); PL-400 isexpiringwithexpiryDate2026-10-30 and hands over to the new AB-400 row (upcoming,availableDate2026-10-16, Microsoft's study-guide outline included); MS-102 and the retired AZ-204, AZ-500, PL-200 and MB-280 name the successors Microsoft recommends; the DP-420 rename (2026-10-06) and PL-400 → AB-400 (2026-10-16) join the timeline; the MB-330 retirement Microsoft expects in December is noted without a date, as it has none; twenty-twolearnUrls that 404'd or pointed at the browse root carry the live credential page. The study-guide outlines were regenerated (AB-400 in, AZ-800/AZ-801 out as retired, MS-721 and SC-300 changed on Learn). GCP: PAA staysupcomingwithout a date and says that GA registration opens November 2, 2026 with the exam expected mid-November; PMLE no longer names Vertex AI, following Google's note on the exam's transition to Gemini Enterprise Agent Platform. AWS: nothing stored moved; AWS corrected its SAP-C02 and DVA-C02 exam pages on 2026-10-02 to the dates the file kept.education-catalogues.test.jspins the re-read. -
Ambassador hub: a program can be additional to another, and MCT Regional Lead now is (owner request 2026-10-05). A program may carry
parentProgramId; the seeded MCT Regional Lead names Microsoft Certified Trainer, and a stored Regional Lead from before takes it on the next read (seedBackfillFor). The child is shown, and its application may start, only while the parent's membership is Active: the Programs tab lists it as its own card right after the parent once that is so (and until then the parent's card says what opens and when), the Settings row disables its Application state with the reason,POST cms/ambassador/applicationsanswers 409PARENT_NOT_ACTIVEotherwise, andGET readiness/{id}reports the gate and counts evidence filed under the parent for the child too. One level only: a parent must exist, be another program and have no parent of its own (400). The Regional Lead seed gains the role's requirements and expectations as application questions (nomination, consecutive years, MCT-eligible certifications, Lounge and Regional Hub activity, the term's commitments), from Microsoft's published role description; the October form's own questions are not published and the seed says to edit against them when the invitation arrives. -
The Coder status token's expiry on the Integrations card, so no calendar is needed (#763).
CODER_STATUS_TOKENis made with a one-year lifetime and nothing renews it.readTokenExpiryinlib/labs/coder-status.jsreads the token's own record from Coder (GET /api/v2/users/me/keys/{id}; the id is the token's part before the dash) andGET cms/labs/coder-token(editor) answers the expiry, the days left and whether that is inside the 30-day warning window. The Hybrid Lab card's test endsStatus token expires on <day> (<n> days), goes red inside the window or when Coder refuses the token, and — for a token made before theapi_key:readscope, which is what lets a token read its own record — saysexpiry unknown: add the api_key:read scope at the next renewal. The README's create line carries the scope. Tests: the key id off the token, the record read and both outcomes of the window, the five reasons, the guard, and the card's sentences. - Ambassador hub: membership status and a direct application-state select
on Settings, documents archived on an application (owner request
2026-10-05). Each program carries a
membershipStatus— Not a member / Working / Active / Denied — separate from the application funnel, set on the Settings row or in the editor and shown as a badge on the Programs card. The Settings row also sets the program's latest application to any state directly: a super_adminstatusOverridethe API accepts outside the transition table and records in the history row (an editor is refused), starting an application at that state when none exists. The application's upload takes documents as well as images (PDF, Word, Excel, PowerPoint, CSV, text): the upload route accepts those types into private containers only, never a publicly served one. That exposed a standing gap (review): a private upload answered no URL and the application cleaner dropped the entry, so every file ever uploaded to an application was orphaned behind a success toast — a file now keeps a{ container, path }reference and a new editor-guardedGET cms/ambassador/files/{container}/ {*blobPath}serves it as a download (private containers,ambassador/prefix only, nosniff, no-store). The application patch writes with the row's ETag and answers 409 on a conflict;statusOverrideis refused on a create; the Programs card always shows the membership badge; the Settings selects wait for the applications read. "ageing" on the Dashboard reads "aging". Tests: the override (403 for an editor, historyoverridefor a super_admin, the plain table still enforced, refused on create), the 409, the file reference and the download route's four refusals, the private-only document types, the membership vocabulary through the form, andlatestApplicationFor. - The Ambassador hub seeds the programs from the owner's documents, walks
the official application questions, and groups and guides the evidence
(owner request 2026-10-05).
seedProgramsran only on an empty container, so a program added toDEFAULT_PROGRAMSnever reached the live one;ensureSeededProgramsnow runs on every programs read and inserts any seed whose id is absent after the highest storedorder, never overwriting a stored program and never bringing back one the owner disabled or soft-deleted; a stored seed that has noapplicationQuestionsor noscoringof its own takes the seed's in the same read (one patch on the row's ETag, nothing else on the document touched), so the live MVP gets its question list without losing an edit. Four programs join the seeds from the program rules as published in 2026 — Microsoft Elevate Educator – Expert (MIEE, with the official 2026-2027 question list and its May to 31 July 2026 window), GitKraken Ambassador, the Microsoft Management Community (scored in credits: ascoringfield with the badge tiers,metrics.creditson evidence, and readiness that sums credits against each threshold instead of counting items) and MCT Regional Lead — and three are updated: AWS Ambassador carries the Charter and Established levels with their contribution counts and the thought-leadership rules, Microsoft MVP carriesapplicationQuestionsmirroring the official form's five sections (profile, network links, the application questions, award category and technology areas, the eight expertise gates with up to 24 tagged activities), and Microsoft Certified Trainer names the annual renewal, the MCT Lounge and the Metrics That Matter classes export, which the Evidence tab now imports as "MCT classes (Metrics That Matter CSV)" through a CSV reader on the existing import route, one manual row per class id (a row without one is skipped and counted by reason; a text over 1,000,000 characters is a 413 naming the limit, and the dialog refuses it first). An imported row is stored under an id derived from its source pair with the atomic create, so two imports racing for one source document leave one row; a missing seed is inserted the same way, and a 409 from an overlapping read keeps what is stored. A program with questions turns the workspace's Responses into a guided form, section by section, each question rendered by its kind (text with the live count, choice, yes/no, URL, a frequency grid, network + URL rows, activities picked from the attached evidence) with Copy on every answer and Copy all for a plain-text packet in the form's order; answers stay inresponses[]as{ questionId, text }with structured kinds JSON-encoded, and the print packet lists the sections. The Evidence tab gains Group by (Source, Program, Year, Verification) with collapsible groups remembered per browser, and a guide above the list, "What each program still needs", with have / need per requirement from the readiness arithmetic, a hint naming the evidence types that would count, and Add opening the editor on that source and program. No answer of the owner's is seeded; the questions are the programs' own.ambassador.test.jspins the seeding on an existing container, the credits arithmetic and the CSV import;validate.test.jsthe question and scoring rules;applicationQuestions.test.js,GuidedResponses.test.jsxandevidenceView.test.jsthe guided form, the grouping, the stored collapsed state and the guide rows. - Certification badges are normalised on upload: PNG or SVG in, a
512 × 512 PNG out (owner request 2026-10-05). The Settings tab promised
"PNG / JPG / SVG" while the upload route refused SVG into
certifications(a publicly served container, where an SVG is a scriptable document), and a PNG was stored at whatever size the issuer shipped.lib/badge-image.jsdecodes every badge with sharp and re-encodes a transparent 512-pixel square (contained, EXIF rotation applied, metadata dropped); an SVG is rasterised at 300 dpi and the PNG is what is stored, which is why the route now accepts the type there. The stored path takes the.pngextension and the response'spath/urlname it; bytes that do not decode are a 415 "Not a decodable image" and nothing is written. Other containers are unchanged (SVG still refused intoblogs/covers,contentstored as sent).admin-uploads.test.jspins the SVG, JPEG, undecodable and as-sent cases with real decoding;sharpjoins the functions dependencies (prebuilt linux-x64 in the lockfile for the deploy runner). - Legacy badge references are re-pointed at publish, so a fifth of the
certification registry gets its badge back (#868). 22 of the 109
published certifications still named the decommissioned Firebase bucket,
which the About page skips rather than renders broken; the migration had
copied the blobs into the
certificationscontainer under the same paths (20 of the 21 distinct ones answer frompublic/media). Newlib/legacy-badge-url.jsturns either Firebase URL shape for acertifications/…object into the media delivery URL;publishSnapshot(the button, and the weeklyreVerifyCertificationstimer) asks blob storage whether each such blob exists, rewritesimageUrl,credentialImageandimage[0].downloadURLin the published item, and persists the same rewrite to the document so the editor's card agrees. A missing blob is reported by name in the response'slegacyBadgesand the document is left as stored; the Publish toast shows the counts. The persist is one conditional write per row on the_etagit was queried with; a 412 re-reads the row, decides again from what is stored now and writes once more, and a second 412 publishes the fresh row as stored. A row whoseimageUrlis a live selection is never rewritten (its old upload metadata would otherwise come back in front of the new badge), and the sanitizer now publishes the editor'simageUrlahead of a legacycredentialImage, which a re-uploaded badge over a migrated row needed. The nightly cleanup's URL parser recognises the media route, so a re-pointed badge is a referenced blob, never a deletion candidate.legacy-badge-url.test.jspins the URL shapes, the alias rewrite, the edited-row rule andrepointAll;snapshots-publish.test.jspins the exists / missing / no-storage / ETag / 412 / edited-row / cleanup cases; the About page's test pins that a dead legacy upload falls past to the published URL. - AI Engine: audio and image tasks join the Tasks table (ADR 0034 slice 5,
#860). The last slice of the ADR: the non-text paths are tasks of the
same registry and selection document as the text ones, and their settings
pages lost the model fields they carried. Registry:
listenAndLearnSpeechandpodcastVoice(tts),coverArtandmanualImages(image), each recommending the model that was its default the day before (Gemini's Economy voice, ElevenLabs'seleven_v3, Replicate'sgoogle/imagen-4-fast) with the reason andasOf: 2026-10-05, andrecordingTranscript(stt),pageOcr(ocr) andembeddings(embedding) registered as planned — no provider carries them yet, so the Tasks tab shows "no eligible model" rather than omitting them, and they have no feature switch and no call site.MODALITIESandCAPABILITIESgain the five media kinds. Media providers in the catalogue: ElevenLabs (GET /v1/models, ids frommodel_id) and Replicate (a static list of the image models the repository calls) are catalogue providers keyed byELEVENLABS_API_KEYandREPLICATE_API_KEY— no card, a key is what switches them on, and the drawer says so;*-ttsandeleven_*ids carrytts, Replicate's andimagen-*carryimage; a price in another unit says so (unit: ElevenLabs per 1M characters, Replicate per image at the figureCONTENTFORGE_IMAGE_COST_USDis set to). The selectable-models rule takes the task'sneedson both sides (selectableModelsFor). Resolver and selection: a media task defaults to mode Recommended, because the Priority list is a chat list a media provider never joins; a custom chain may name only providers that can carry the task's needs (a 400 on save, dropped on read); each media task carriesonly, a product rule in code no document lifts — the podcast voice is ElevenLabs-only, Listen & Learn speech Gemini-only, the images Replicate-only (ADR 0029 §2b) — and its recommendation stands while the catalogue has not yet confirmed it, so the week between a deploy and the next refresh is not a week of silence. Migration: a stored Listen & Learn model (Platform settings) or aLISTEN_AND_LEARN_ELEVENLABS_MODELsetting that differs from the recommendation becomes that task's custom chain (thenGlobal: false), on a v1 document and on a v2 document saved before the task existed, so nothing changes for the owner on merge. Call sites: the Listen & Learn jobs, the podcast pipeline and its live check, and the Replicate client read their model through one router door,modelForTask({ task })— the feature switch, the key check, the pin and the locks all apply — and the per-runttsModel, the per-bookvoice.modeland the stored default are ignored on write and dropped on read; Azure AI Speech stays the Listen & Learn adapter's own fallback. Settings pages: the Platform settings Listen & Learn voice card, the Generate tab's voice model field, the book dialog's Gemini model select and the Settings tab's model list are gone; each says "The model is chosen under AI Engine → Tasks" with the link, andcms/platform-settings/listen-and-learn-speechleft the allowlist. Usage by task: the audio and image rows carryai:<task>as their source, their old slug (listen-and-learn:audio,podcast:audio,images:cover,images:manual) asproduct, so Usage & Cost groups by task ("Breakdown by Task"). Tasks tab: the new rows render with their modality badge, the chain editor and the exclusions offer a media task the providers that carry its needs under its product rule (ElevenLabs and Replicate among them, named without a card), and a media task's Test is a dry run — the candidate it would use and the resolver's reason, no audio or image made, said on the line. Both suites,ai-call-sites.test.jsreadingmodelForTask({ task })besidefeature:, and a newlib/ai/media-tasks.test.jshold it; the ADR records the slice. - AI Engine: the Priority list and the Tasks tab (ADR 0034 slice 4,
#859). The three overlapping controls the ADR's context describes are
gone from the page and one list and one table stand in their place, both
reading and writing the version 2 selection document (slice 3). AI
Services gains the Priority list above the cards: the providers a call
may reach, in
global.priorityorder, P1 badged as the default, each row with a model dropdown fed by the catalogue ("Provider default" or a selectable model) and a one-line "will use" for text and vision, moved with keyboard-reachable Up and Down; providers that are switched off, hold no key or are not named yet are listed below with the reason. Tasks (the Routing tab renamed; its?tab=routingaddress andtasksboth open it) is one row per task from the registry: label, description, modality, a public badge, the mode as radios — Recommended with the registry's reason andasOf, Global (P1), Custom — the effective model ("gpt-5-mini via Foundry", or the provider default with the model it was judged on) with the first turned-away candidate's sentence under it, badges for a recommendation that did not lead, a custom chain that fell through, an unpriced model or nothing eligible, an exclude control per provider, and for Custom a chain editor of up to four provider → model rows whose model dropdown is filtered by the task'sneedsthrough the catalogue's capabilities, with a "then the Priority list" switch. A row is a draft until Save; every save PUTs the whole document with theupdatedAtit read, a 409 reloads and says "changed elsewhere" rather than overwriting, and a document the API refuses ("this task would have no model") is said on the row with the API's sentence. The effective model is the router's own answer, never a copy of the resolver on the page:GET cms/ai-routing/effectiverunsselectChainfor every task over the configuration the router reads (router.js resolveEffectiveSelection: the same loader aschainDetails, cache dropped first) and answers per task{ mode, chain, rejected, flags }beside the registry entry and the document's, per Priority row what a null model resolves to (defaultsper purpose fromDEFAULT_MODEL_TABLEwith environment overrides, the modality recommendation it is judged on), andavailability;effective-selection.contract.test.jsholds the route's answer for every task equal toselectChain's on the same documents under three document sets and two key sets. A per-task Test,POST cms/ai-routing/test/{task}, walks the task's effective chain with the card Test's prompt and caps (proxy.js testTaskCandidate: 16 tokens, 45 s, usage sourceai-engine:task-test, nothing written onto the cards) until a candidate answers and reports who did, how fast, and why each one above it did not; the chain is the resolver's, so the public explain route is never tested against the trial tier (ai-tasks.test.jspins it with the real router over a document that puts the trial tier first). A Model catalogue drawer opens from both tabs: every provider's models with capabilities, price or "unpriced", status, first and last seen, Hide and Show, each provider's refresh age with "Refresh now", and a banner naming retired models the Priority list or a custom chain still names, read from the resolver's rejections. Anullmodel keeps meaning the provider's default per purpose (ADR 0034, amendment of 2026-10-05 under "Consequences and accepted risks": §2's modality reading would have moved every drafting task to nano against the owner's 2026-10-04 decision; the Tasks tab's Recommended mode is how a task gets the registry's recommendation). Removed: the per-provider placement selects under "Where AI is used" (the switches stay) and the API's placement field (PUT cms/ai-featurestakes only{ features }; a storedplacementstays on the document as the migration's input until the first v2 save), the card's model pin (setProviderModel) and the order-of-preference card (setProviderOrder; the cards now follow the Priority list), the v1 Routing tab (RoutingTab.jsx,RouteEditor.jsx,routingModel.js,useRoutingTable.js,setAiRoute,setAiPlacement), and the v1{ routes }body onPUT cms/ai-routingwith theroutesandmaxFallbacksfields of its GET, which nothing sends or reads any more.features-catalogue.js's placement functions androuting-table.jsstay, marked deprecated:migrate-selection.jsimportsplacementFor,PROVIDER_PLACEMENT_DEFAULTSandnormalizeRoutingto read a stored v1 document until its first v2 save. Pinned byTasksTab.test.jsx,PriorityList.test.jsx,selectionModel.test.js,AIEnginePage.shell.test.jsx(the five tabs with Tasks, the Priority list from the document, the drawer),AIEnginePage.test.jsx(the switches without a placement),tabs.test.js,aiEngine.test.js(the new usage source has a label),admin-integrations.test.js(the placement body refused, the v1 routes body refused, the v2 PUT and 409 unchanged),ai-tasks.test.js,proxy.test.js, the route inventory and the API contract (.azure/api-surface.json), withpublic-copy.test.jsandeducation-a11y.test.jsgreen beside them; the page names a provider only through the card's name or the registry, never in a sentence of its own. No app setting or Terraform run: the first save from either tab writes the v2 document, and the deploy carries the routes. - Docker article series, part 3: "Publishing to Docker Hub from GitHub
Actions without a token" (#787).
docs/content/blog-docker-03-docker-hub-oidc.md, a how-to taken from the live setup (#790, thepublish-dockerhubjob and the Docker Hub publishing runbook): why a stored Docker token is the liability, how the OIDC exchange works (GitHub's ID token for the audiencehttps://identity.docker.com, the RFC 8693 exchange at Docker, a 300 s token), finding the subject the repository presents (gh api .../actions/oidc/customization/suband the immutable-form string built from the owner and repository IDs, plus a step that prints a real token's claims), the connection form in Docker Home with every field value, the ID and switch as repository variables, the job itself (login with no password, copy by digest with--prefer-index=falseand a read-back, one attestation per registry), verification from the outside (a pull by the GHCR digest,gh attestation verify --bundle-from-oci --source-ref), deleting the old secret, and the failure table (400 access_deniedand the explain step,insufficient_scope, the expired token, the digest mismatch, the plan gate), with the one-off local copy for an organisation that cannot create a connection yet. Parts 1 and 2 now readof 3. The owner imports it from /admin/drafts and sends it to In Review; the article's names are the template's generic ones, and the verification runs against this site's real image so the output is real. - Replicate image spend is recorded and bounded (owner request
2026-10-05). Replicate bills on its own account, where no Azure budget
sees it, and until now a generated image carried a per-image price for
the editor's caption and nothing else. Every image the change feed or
the Images pages generate now writes an
ai_usagerow (sourcesimages:coverandimages:manual) priced atCONTENTFORGE_IMAGE_COST_USD, which Terraform sets to0.02(thegoogle/imagen-4-fastprice on replicate.com that day), so Usage & Cost shows Replicate beside the token providers. Before every generation the client sums the month's rows and refuses withIMAGE_BUDGET_EXHAUSTED(429) atCONTENTFORGE_IMAGE_MONTHLY_BUDGET_USD(Terraform:10) or atCONTENTFORGE_IMAGE_MONTHLY_MAXimages (200), counting the image it is about to make, so an unpriced month still has a ceiling. The guard fails closed: a month that cannot be read refuses with 503, because no account-level stop is configured (the owner declined a Replicate-side spend limit on 2026-10-05; the application guard is the only stop). Concurrent generations can overshoot by their number times the per-image price, a few cents, which is accepted and documented. The manual image routes answer the guard's 429 or 503 with its code rather than a generic ai-cover.test.jspins the row, the unpriced row, both ceilings, the pending-image rule and the fail-closed rule;manual-images.test.jspins the route's 429.- The AI task registry, with a recommended model and the reason per
task (ADR 0034 slice 1, #856).
functions/src/lib/ai/tasks.jsnames every piece of work the site sends to a model: label, description and route as before, plus the kind of answer it needs (modality), the capabilities a model must carry (needs), whether anonymous visitors can trigger it (public), andrecommended: { provider, model, reason, asOf }.AI_FEATURESis derived from it, so the feature switches, the call-site test and the portal's toggles are unchanged.provider-recommendations.jscarries each provider's best model per modality for thenullentries of the global priority list to come.tasks.test.jsrefuses a recommendation that names an unpriced model, a public task that recommends the trial tier, or a grounding need outside Gemini. Nothing reads the new fields yet; slices 2–5 do. - The model catalogue, refreshed weekly from the providers' own lists
(ADR 0034 slice 2, #857). Each AI Services card's model list was typed
into the frontend seed, so a new deployment, a retired id or a price
change was a pull request, and a card could offer a model its provider
had stopped serving.
functions/src/lib/ai/model-catalog.jsnow keepsadmin_settings/ai-model-catalog(besideai-featuresandai-routing; ADR 0034 §1 namesadmin_config, which holds nothing the AI code reads): per providerrefresh: { lastOk, lastAttempt, lastError }and per modelstatus: live | retired | unknown,firstSeen,lastSeen,missed,hidden, capabilities and context from a code table keyed by id pattern, and pricing fromCOST_TABLEorunpriced: true. The weeklyprobeAiProviderstimer lists every provider with a key after its Tests (OpenAI and NVIDIA/v1/models, Anthropic/v1/modelspaged, Geminimodels.listfiltered togenerateContent, Foundry/openai/v1/modelswith the app identity's token); a catalogue failure is logged and reported and never costs the Tests already written. A 200 without the provider's collection is a malformed list, not an empty one. A failed list changes no model — the stored models are written back byte for byte — and records onlylastAttemptandlastError(ADR 0034 review finding 3);retiredtakes two consecutive successful lists without the model; a refresh never toucheshidden. Both writes are ETag-conditioned (replaceDocIfMatch, re-read and re-apply on 412), so a Hide and a refresh landing together both survive. Three editor routes:GET cms/ai-model-catalog(every provider,stalewhenlastOkis missing or older than 8 days, the router's defaults seeded in memory under whatever is stored until the first successful list),PATCH cms/ai-model-catalog/{provider}/{model}with{ hidden }(the id URL-encoded, since NVIDIA's carry a slash) andPOST cms/ai-model-catalog/refresh; every write drops the router's cache. A card's selectable list is live or unknown, not hidden, and carrying thetextcapability; the speech, transcription, embedding and image ids the lists also return stay in the disclosure only. The AI Services page reads the catalogue once and after every hide or refresh, merges each card'smodelsfrom it in one place (frontend/src/lib/aiEngine/catalog.js), shows a "Models (N)" disclosure per card withretired,unpricedandhiddenbadges, a Hide / Show per model and the "List refreshed" line, and a "Refresh model lists" button in the section header. The seed'smodelsarrays andproviderModelPatchesare gone; a pin the catalogue no longer lists reads "(not listed)" on the select instead of being released.model-catalog.test.jspins the adapters, the enrichment contract (every router default and every recommendation priced; every capability from the registry's vocabulary), the retire rule, the failed-refresh rule and the read rule;model-catalog-handlers.test.jsthe routes and the encoded id;ai-provider-probe.test.jsthe order and the isolation of the catalogue step;aiEngine.test.jsholds the page's visibility rule equal to the API's and refuses a model list in the seed. No app setting or Terraform run: the first list lands on the next Monday probe, or on the button. - AI model selection: the version 2 document, the resolver and the
migration (ADR 0034 slice 3, #858). Three mechanisms decided which
provider served a call — the cards' order, the per-feature placement and
the v1 routing table — and they overlapped.
admin_settings/ai-routingis now version 2 (functions/src/lib/ai/selection.js):global.priority, the Priority 1, 2, 3 … list of{ provider, model|null }, and per task{ mode: recommended | global | custom, chain?, thenGlobal?, exclude? }. One pure resolver,select.js, decides the chain for every call and nothing else does — explicit call-site model, then the task's recommended model if it islive, priced, keyed, enabled and carries the task'sneeds, then a custom chain and the Priority list after it, then the Priority list — with every candidate filtered by key, switch,exclude, catalogue status (retired out; unknown or not yet listed allowed with a flag, so a fresh deployment never goes dark) andneeds, and the policy locks no document lifts: the trial tier never serves a public task or a call that names no task, and grounding is Gemini-only. A turned-away candidate is inrejectedwith its sentence ("not eligible: trial tier on a public route"), never silently dropped. The migration (migrate-selection.js, pure, idempotent on a v2 document) keeps every choice: card order and pin → the Priority list; a v1 route → a custom chain withthenGlobal: true; a placement offirst→ that provider after the route's steps (the route ran after placement) or alone as the chain;off→exclude; everything elseglobal. The config loader reads the catalogue beside the three documents (same cache, a catalogue that cannot be read is null) and migrates a v1 document in memory on every read, writing nothing; the router'schainDetailscalls the resolver,applyFeaturePlacementandapplyFeatureRouteare no longer called and their modules are marked deprecated, an empty chain for want of a capability fails withAI_NOT_CONFIGUREDnaming it, theCONTENTFORGE_AI_PROVIDERpin keeps its meaning (one provider the resolver found eligible, no fall-through), and every usage row gainsselection: explicit | recommended | custom | global.GET cms/ai-routinganswers the v2 document (migrated: truewhen derived from a stored v1) beside the v1routesview the Routing tab still renders;PUTtakes a v2 document (validated whole, 409 on anupdatedAtthat is not the one read) or the v1{ routes }body the tab sends, and stores version 2 from the first save either way — after which the "Where AI is used" placements and the card pins no longer reach the router, having been migrated into the document; slice 4 (#859) removes those controls. Anullmodel in a chain or the list stays the provider's default for the call's purpose (DEFAULT_MODEL_TABLE), judged for eligibility on the provider's modality recommendation and reported asmodalityModel; making it that recommendation outright (ADR 0034 §2) is one marked line for slice 4, when the Tasks tab lets the owner choose Recommended for the drafting tasks the owner's 2026-10-04 decision keeps on GPT-5 mini.select.contract.test.jsholds the resolver, on a representative fixture of the 2026-10-04 documents (fixtures/selection-2026-10-04.js; the production export was not readable from the session) migrated to v2, to the chain the pre-slice router produced for every task under five document variants and four key sets, before and after the first catalogue refresh; the two differences are documented there (the grounded task's chain is Gemini alone, as the grounded call always was; a call naming no task may use Foundry, never the trial tier).select.test.jspins the precedence and the locks in both directions (the public explain task cannot be given NVIDIA by the list, a chain or a recommendation; a document cannot enable a provider with no key);selection.test.jsthe read and save rules of §6;migrate-selection.test.jsthe migration;admin-integrations.test.jsboth PUT shapes, the 409 and the "would have no model" refusal. No app setting or Terraform run: the first admin save writes the v2 document. -
OpenAI's gpt-5-mini and gpt-5-nano are priced ($0.25 / $2.00 and $0.05 / $0.40 per 1M tokens, OpenAI's published rates, the same figures Azure lists for the same models; owner confirmation 2026-10-05). They had been unpriced since ADR 0033 pending that confirmation; rows written before stay at $0, flagged. The OpenAI card's note says so.
-
Microsoft Foundry as a paid AI provider (#849, owner decision 2026-10-04). Of a budget of about USD 75 a month, the first call was Foundry with the cheapest text models: gpt-5-nano ($0.05 in / $0.40 out per 1M tokens) for short calls and gpt-5-mini ($0.25 / $2.00) for drafts and analysis. A fifth provider
foundryin the AI router on the account's OpenAI v1 chat endpoint; placed per feature like NVIDIA, first for the content features and locked off the anonymous public explain route and the grounded call; priced at the Global Standard rates in the cost table. No key:infra/foundry.tfcreates the AIServices account with local authentication off, the two deployments (Global Standard, centralus), the Cognitive Services OpenAI User role for the Function App's identity, and a USD 75 monthly budget on the returningairesource group with alerts at 50, 90 and 100 %; the router sends an Entra token through@azure/identity, cached until five minutes before expiry, andFOUNDRY_ENDPOINT(set by Terraform) is what makes the provider available. The AI Services card, its setup instructions and the Required-Inputs row follow.foundry-provider.test.jspins the request, the token cache, the placement and the pricing. Live after the owner's TFC run. -
Setup instructions for OpenAI and NVIDIA on the AI Engine. The book icon on each provider card opens
/admin/ai-engine/docs/<provider>, and for OpenAI and NVIDIA that page said "Documentation not found": the docs data (frontend/src/data/serviceDocsData.js) had entries for Gemini and Anthropic only, written before the other two were wired. Both now have the same shape as the others (requirements, where the site uses them, configure / use / disconnect / advanced, references). NVIDIA's carries the day's findings: GLM-5.3 as the only default, the pacing guard, and the wait before judging a freshly seeded key (#701). -
ContentForge alignment: one content operating system (ADR 0033, owner request 2026-10-03). Twenty-six admin pages inventoried, then rebuilt in eight slices so a first-time user can read the menu, every stage of the pipeline agrees with every other, and nothing a feature creates is lost when the page is left. The brief, findings and acceptance criteria are in
docs/decisions/0033-contentforge-alignment.md; a Figma file holds the redesigned Home, Forge Studio, Image Gallery, Calendar, Audio Library and Ambassador screens. - Approve works again. Every Approve button — queue, review boards,
editor save bar — sent
approved_blog, a status the backend normaliser never mapped, so the transition table answered 400 "Invalid transition" from every surface. The normaliser mapsapproved_blogandpublished_blog; the pages send the canonical names. The Queue "Approved" chip, the Editor list and the Frameworks / Coder Corner filters queried statuses never stored and always came back empty; they now query the stored ones, andforge_readyandneeds_rework— counted in the Editor badge but shown on no screen — have a chip. - Home. One pipeline graphic (New Content → Drafts → Review Queue → Editor → Publish → Live Pages) replaces the two identical strips; an Explore section introduces every menu group in a sentence. Sidebar and dashboard read one counts hook that refetches on navigation, so the badges follow a Recount.
- Sidebar reads one registry (
frontend/src/config/adminNav.js) with a description per item shown on hover and repeated under each page title. Live Pages joins Pipeline; the Content group becomes Enhanced and takes Listen & Learn and Labs; Spotlight gains Ambassador; Prompts is Image Prompts. - Shared primitives for every hub: PageHeader (purpose line, collapsible
"How this works"), StatusBadge over one status vocabulary
(
lib/status.js: healthy / degraded / misconfigured / unavailable / unknown; content statuses with their help sentences), EmptyState, TaxonomyPicker and TaxonomyChips,useGuardedLoadanduseWriteGuard. - Content taxonomy. Every content record can carry
kind(what it will become) andideaOrigin(how it became an idea), two separate lists edited on Platform Settings → Content types & origins (admin_config/content_taxonomy; built-ins can be disabled, never removed). Older records derive both fromtypeandsourceat read time, so filters and chips agree with no backfill. Pickers on New Content, Drafts, Forge-from-URL, Forge Studio and the Review page; chips and filters on every pipeline list. - Pipeline pages. One
useContentTransitionshook replaces three hand-rolled approve/reject/restore copies; Frameworks and Coder Corner share one typed list; Architecture and Framework boards share one shell; Coder Corner items get their own board on the Review page. The Review page shows save and publish errors and never navigates on a failed save. Live Pages asks the server for live pages ordered by publish date instead of filtering 500 arbitrary documents. The editor's Metadata tab gains History:content_versionswas written on every save and read by nothing; it now lists versions and restores one as a new version. The purge timer updates the dashboard counters it used to drift. Duplicate client audit rows removed where the server already writes one. - Forge Studio is a workspace, not a settings form: Start (idea,
template, existing content, URL, blank) → Brief (objective, audience,
kind, origin, tone, length, must and must-not cover, sources, channel,
SEO) → Draft (the document is created on the Drafts page first, the forge
runs against it, ten AI actions apply only on an explicit button, every
AI edit recorded in
activity[]) → Finish (send to review, open in editor, publish, social post, images, audio — each with a destination). The old configuration form is the fifth tab, with its zero-value, chip-save and ETag bugs fixed. The manual forge job's store lackedincrementIf, so the budget claim threw; fixed with a test. - AI Engine gains per-task routing (
admin_settings/ai-routing: primary provider and model plus fallbacks per feature, read ahead of the global order; Simple and Advanced views), an "Auto (per task)" model option, usage recorded once per call for every call site (nine of fourteen recorded nothing), unpriced models shown as unpriced rather than at gpt-4o rates, and config invalidation after every write. A custom MCP server can no longer name an arbitrary app setting as its bearer key and must be https. - Image Prompts and Image Gallery share lineage. A prompt set is an image set: purpose, theme, style rules, negative prompt, version history, duplicate, rename, archive, a Generate sample, and every image it produced. The AI cover uses the assigned set's prompt and style rules (the hardcoded "Lego minifigure" prompt is the last fallback) and generated rows record set, prompt, template version, provider, model, size and path; regeneration writes a new stamped blob instead of overwriting. The gallery is a visual library: search, filters, sort, paging, bulk tag/move/archive/trash with a confirm, a details dialog with alt text, caption, licence, dimensions, "used by" and a link to the set, import from URL, persisted folders. Thirteen listed gallery bugs fixed, among them "Update selected" updating one image and tag toggles erasing tags.
- Calendar aggregates every scheduled kind through one read
(
GET cms/calendar): content publishes and failures, newsletter issues, social posts, talks and CFP deadlines, certification expiry and renewal, Ambassador deadlines, audio releases. Month, week and agenda views; filters; drag-and-drop rescheduling with a keyboard alternative; unschedule; quick create; conflict marks; a refetch after every change (the old page never refetched). The Newsletter Hub embeds the same calendar filtered to its issues. - Newsletter. Issues reach
sent: nothing moved a scheduled issue pastscheduled, so metrics never appeared. Reconciliation reads the Resend broadcast on read and on demand; cancel, reschedule, retry and duplicate from the hub; per-issue send time; version history; manual content blocks (a live article or a custom block with a gallery image) beside the automatic sections; the From address is a setting; audience CSV export, add a subscriber, whole-list search. Social Hub's header reflects the real Publer accounts call, and a scheduled post can be edited and rescheduled. Recording Hub's "Send to pipeline" called a function it never imported and crashed after creating the content; fixed, with the editor links. - Listen & Learn is an Audio Library. Book or course → chapter or lesson → audio version, on the two existing containers. The hub opens on a Library of books with covers and durations; a book lists its ordered chapters with status, player (position remembered), Approve, Regenerate, Versions, Rename, Archive and Delete; new books from pasted text or a content item, with a cost estimate before generating. A failed regeneration no longer unpublishes a chapter (it keeps the current take and offers Retry); regeneration writes a new stamped file instead of overwriting one served under a one-year immutable header; re-running a guide keeps approvals and order; areas dropped from a guide are flagged. Text-to-speech defaults to the cheapest Gemini model with the estimate stated, chunks long dialogue on sentence boundaries, and takes per-book voice, language and rate. Publishing is publisher-gated.
- Labs live under each provider's Learn section
(
/:provider/education/labs), with objectives, prerequisites, steps, resources and a panel that explains Lab, Desktop and Agent in visitor words;/education/labsstays as the index grouped by provider. The catalogue carries providers, difficulty, steps and validation, and a new lab family is one row and one template entry. The admin hub gains a Catalogue tab with Validate, and a Concepts panel with live status. Job listing and claiming are ordered; the deadrunningstatus and the stale.envinstructions are gone. - Speaking and Certifications. Status, CFP deadline, sessions and evidence on talks; renewal date, requirements, evidence and validation on certifications; dates stored as plain days so nothing shows a day early; editors are the shared dialog; "Show on site" off now hides a Sessionize event publicly; "Feature in Spotlight" has a public effect; Publish snapshot has an effect before the next deploy (the live snapshot wins when newer); the speaker id is no longer hardcoded on About.
- Ambassador, new under Spotlight: programs (seven seeded, all editable, disable never deletes), applications with a status machine, deadlines, renewals, files and history, an evidence library that imports from Speaking, Certifications and published content without re-entry, readiness per program that explains its arithmetic and never promises acceptance, an application workspace and packet view, and Settings as the last tab. Private by default; soft delete; audited.
- Health probes every hub and dependency through a registry of 36 checks with one vocabulary, last-checked times (every time said "Not available": the formatter expected Firestore timestamps and Cosmos stores ISO strings), impact, fix and a deep link; Test all runs the safe ones in parallel. Integrations gains service cards for every AI key and the lab host, with persisted last-worked / last-failed status. Platform Settings opens on an index of every setting document and where it is edited.
- Infra. One new Cosmos container,
ambassador(/id), declared in the migration manifest andinfra/cosmos-containers.json. It needs aterraform applywith owner review; until then the Ambassador hub shows a not-provisioned state and names the plan command, and nothing else breaks. - Links to the non-existent
/admin/platform-settingsfixed; dead exports removed fromconfig/admin.js. -
Code structure. Qlty measured 180 code smells the first cut introduced and 57 pre-existing ones it made worse (handler factories every slice added routes to, files past their complexity budget). All were restructured away rather than suppressed: handler bodies moved out of their factories into module-level functions over a context, request validation into tables, large components into reducers, hooks and per-section components, large modules into sibling modules behind a re-export shim. Every extracted helper has its own test (about 350 new tests); existing tests pass unchanged; four pre-existing
eslint-disable complexitycomments were removed because the code no longer needs them. -
Drafts: a ContentForge stage before the Content Queue, at
/admin/drafts(#840, owner request 2026-10-03). Articles drafted asdocs/content/*.mdfiles could only be read on the site by importing them straight into review. They now have a stage of their own, saved in the site: create, edit, delete and push forward from any device, with no GitHub credential and no pull request per save. - The page. A list of drafts on the left; on the right an editor for the seven front-matter fields the repository drafts carry (title, subtitle, date, track, part, tags, reading) and the markdown body, with a live preview through the article renderer. New draft, Save, Delete (confirmed), Send to In Review and Back to Drafts; the save state (saved, unsaved changes, saving, error) beside Save; a warning before leaving with unsaved changes, whether by switching drafts, following a link or closing the tab. Its menu entry, Drafts, sits immediately above Review Queue.
- The data model needs no infrastructure change. A draft is an ordinary
contentdocument at the newcontentStatus: 'drafting',Live: false,Status: 'Draft'— same container, same shape as the repository import's documents, no Terraform.draftingis deliberately notdraft, whichforgeScheduledpicks up to rewrite. The state machine gains two edges,drafting → in_review(Send to In Review) andin_review → drafting(Back to Drafts, only for an article that came from Drafts and is not approved or live); both are walked only by the Drafts routes, andtransitionContentStatusrefuses them (DRAFTS_STAGE). A draft is kept out of the queue filters and the dashboard counters. - Never public.
isPublicDocumentrefusesdraftingoutright, and a draft's fields fail the public list query and the manifest/pre-render query;drafts.test.jsevaluates both SQL predicates, as written, against every draft shape, so a predicate change that let one through fails there. - Send to In Review moves the same document to
in_reviewin the end state the old import produced — body normalised, dedup title, quality and image reports, provider from the tags when none is set — so it lands on the review board and goes on to the Publish Queue as before. Delete removes a draft or an article from Drafts still In Review (one document, so both go) and refuses anything live with a message saying so. - Two tabs cannot overwrite each other. Save, delete and both
transitions carry the version's ETag and are written conditioned on it
(
patchDocifMatch, and a newdeleteDocIfMatch); a stale one is a 412CONFLICT, and the page keeps the owner's text and offers Reload latest. - Migration. "Import from docs/content" on the page reuses the existing
parser, allow-list and pinned GitHub fetch to bring the ten
blog-*.mdarticles in once, as drafts (blog-template.mdandblog-machine.mdare contracts and skipped). It is idempotent: a path already claimed byrepoPathis skipped before any fetch, and the create is create-only under the id derived from the path. The two Docker drafts the old import put In Review show on the page as In Review, with Back to Drafts. - Retired: the Content Queue's "Import drafts from the repository" panel
and its two routes (
POST cms/content/import-repo,GET cms/content/import-repo/candidates), which wrote straight to review and refreshed in-review articles from the repository — which would now overwrite edits made on the Drafts page. The queue links to/admin/draftsinstead. Eight admin-only routes undercms/draftsreplace them, behindrequireRoleat editor, in the route inventory and the API contract. Thedocs/contentfiles stay as an archive;blog-template.mdandblog-machine.mdsay the Drafts page is the source of truth after import. - Docker guides: building images, and the Docker Desktop app (#772, #773). The Docker hub's first two focus areas said "Coming soon". Both now link to a page of their own, the way the sandbox recipe does (#774) and the way Terraform's sections link to theirs, and all three areas read "Available now".
/docker/building-imageswalks throughlab-image/Dockerfileandpublish-lab-image.yml: the five stages and therunnerandfulltargets, the base image pinned by digest and the workflow check that holds it toversions.env, pinned sums on every direct download, the provider mirror and the vendored Azure Verified Modules, the non-root user,smoke.sh, the provenance attestation, and the push to GHCR. Each command is printed for PowerShell and bash./docker/desktopinstalls Docker Desktop on Windows and macOS and covers the dashboard, the WSL 2 backend, resource limits (.wslconfigunder WSL 2), the labs' "Run it locally" line, and how Desktop relates to Docker Engine on the lab host. The run line is imported from the lab catalogue, not copied. The release (4.93.0 of 2026-09-28), the subscription terms and the requirements were read from docs.docker.com on 2026-10-02, and the page carries that date.- Both pages are static routes. They are in
STANDALONE_ROUTES, so they pre-render, and they are page types inrouteFactory, so the Docker hub header stays on them.DockerGuides.test.jsxchecks each page's sections, commands and dates, and holds the building-images page to the Dockerfile,versions.env,smoke.shand the workflow, so a change that falsifies a sentence fails a test. The pages passpublic-copy.test.jswith no new allowance: the runner image's registry name and written-out digests stay off the page. - Three weeks' warning before a catalogue row goes stale (#818). The catalogue alarm runs on the real clock. On the day a stored status is overtaken by its own dates, CI fails on every branch until someone re-verifies the row. #770 is that day for three rows, and until now the only warning was a hand-filed issue.
- A new
findStatusesDueWithinincertStatus.jsasks the alarm's ownfindStaleStatusesabout each day ahead, so the warning and the alarm cannot disagree. frontend/scripts/catalogue-due-soon.mjslists the rows due within 21 days, across certifications and Azure's applied skills.- The new Monday workflow
warn-catalogue-due.ymlkeeps one issue, labelledcatalogue-due, in step with that list: it opens the issue, refreshes it, or closes it when nothing is due. - The catalogue list now lives in one module,
src/data/catalogues.js, shared by the alarm and the warning.
AWS's SAP-C02, DVA-C02 and MLA-C02 pages were re-read on 2026-09-29. The
one-day conflict between the exam pages and the blog's Key dates is
unchanged, and the file records that and keeps the blog's dates. The first
rows the warning will list are SAP-C02 and SAP-C03, in the run of
2026-11-02.
- Tests for the untested edges (#817). The sweep of 2026-09-29 found
modules where an untested line is a security, money or data risk. Each now
has a test:
- telegram-http.test.js. A missing, empty, raw-token or wrong
webhook secret gets 401 before the bot, the job store or the queue is
touched. An unset token, or an unresolved Key Vault reference, gets 404.
Once the secret is valid, bad JSON and a throwing bot still get 200, so
Telegram does not retry into a storm. The enqueue writes a queued job as
telegram-bot@system.
- secret-vault.test.js. One slash in the vault URL, the name encoded,
and a refusal naming the secret and status but never the value or body.
The reference refresh reports every failure instead of throwing.
- flag-gate.test.js. Only an exact "true" arms a timer; only an
exact "false" master stops them all. The first skip warns, later ones
log at Information.
- default-agent-guard.test.js. Nothing is built at import, and it is
built once. Lookups read lab_agents, and denials write
agent-auth-denial to admin_audit_logs.
- vps-agent/lib/api.test.js. Every POST carries the bearer token, and
the agent's own agentId cannot be displaced. A refusal carries the
server's status and message, and claimJob answers null when there is no
job. api.js gains an injectable credential, next to its fetchImpl.
- route-inventory.test.js. Every change feed has
startFromBeginning: false; the content feed takes 8 documents a batch
and the others 50; an empty batch does nothing.
- scripts/generate-cosmos-container-spec.test.mjs. Runs the
generator's --check, documented for CI but never run by any workflow,
and pins content_versions on /contentId and cp_sortDate on
content and blogs.
- Vault on the lab host: auto-unseal with a lab-only Key Vault through the
Arc identity, built and off (#726). First, the question #726 asked: can
Vault 2.1.1's azurekeyvault seal sign in through the Arc agent's local
endpoint with no secret on the host? Yes, from the pinned release's source:
with no client_id the seal (go-kms-wrapping azurekeyvault v2.0.14) uses
azidentity v1.13.1's default chain, whose managed-identity credential hands
over to MSAL for Go v1.6.0, which detects an Arc machine and runs the
agent's challenge-file flow; go version -m on the signature-checked
binary lists those versions. HashiCorp documents managed identity only for
Vault "hosted on Azure", so this rests on the code. infra/lab-hybrid.tf
adds a Standard Key Vault kv-labhybrid-prod-cus-01 (RBAC, purge
protection, prevent_destroy, AuditEvent to the Management workspace;
the pattern's kv-lab-hybrid-prod-cus-01 is one character over Key
Vault's 24), one RSA 3072 key vault-seal allowing only wrapKey and
unwrapKey, created through Resource Manager so the run identity needs no
data-plane role, and the Arc identity's only grant, Key Vault Crypto
Service Encryption User on that key, for the principal read from the Arc
machine at plan time (nothing is planned while it is absent). About $0.13
a month, from Vault's 10-minute seal health check at $0.15 per 10,000
advanced-key operations. The vault role gains an opt-in
seal "azurekeyvault" stanza with no client id and no secret, switched on
by the host fact /etc/ansible/facts.d/hcw_vault_seal.fact as Arc's is;
it reads the key as the Arc identity before writing the stanza, refuses to
drop the stanza while Vault's data is under it, pins the credential chain
to managed identity, adds the himds group to the unit only, and lets
systemd retry every 30 seconds while Key Vault is unreachable. With the
seal off the unit and configuration render byte for byte as before, so
merging restarts nothing. bootstrap.sh --tags vault_seal_check runs the
checks alone. The migration from Shamir (vault operator unseal -migrate,
each key at Vault's own hidden prompt) is a runbook section of owner lines,
rehearsed on 2026-09-29 against the real 2.1.1 binary in an Ubuntu 26.04
container with stand-ins for the agent and Key Vault: the refusals, the
migration, Sealed false after systemctl restart vault, a Key Vault
outage across a restart, both ways back, and a rebuilt host. The rehearsal
found that a restart before Vault logs seal migration complete leaves it
migrating again, so the runbook waits for vault status to drop the
migration line. ADR 0032's amendment of 2026-09-29 records the decision
as proposed and the trade as the owner's to accept: with auto-unseal, root
on the host together with the Arc identity can unseal Vault, and the five
keys become recovery keys that cannot. scripts/lab-host-vault-seal.test.mjs
holds the vault, key and machine names in infra/ and the role together
and pins the grant's shape; assert-expected-plan.test.mjs now compares
its permanent-replacement list with azapi's patch resources only, since an
azapi_resource is an ordinary managed resource.
- Docker news feed, podcast page and home hub (#777).
- News feed.
PROVIDER_FEEDS.dockerinfunctions/src/lib/rss/feeds.jsreads Docker's blog athttps://www.docker.com/feed/, the addresshttps://www.docker.com/blog/feed/redirects to. On 2026-09-29 the ingest's own parser and User-Agent read it as RSS 2.0 with 10 items, the newest from 2026-09-24. The feed's display name is Docker./docker/newsand/docker/rssare now the sharedNewsPage, which already had Docker'sNEWS_METArow, andrssFeedsinProviderContextnames the feed.scripts/verify-timer-witness.mjsqueries Docker's feed route too; its test already held its list to the feed catalogue's keys. - Home page aggregate. Nothing on the site reads the
homepage_feedsaggregate today. With a ninth provider in it, it now carries nine first-pass items and one second-pass item, where there were eight and two. - Podcast.
PodcastPagehas a Docker row inPROVIDER_META.ProviderAudioDispatcherroutes Docker there. It is now one membership test againstVALID_PROVIDERSinstead of a branch per provider; Docker would have been the tenth identical branch.provider-coverage.test.jsreads that form as "every provider".PODCAST_PROVIDERSnames Docker, on the server and in the admin copy./docker/audioand/docker/audio-architecturerender and pre-render instead of answering 404.
- Home page. "Quick Access Hubs" ends with a Docker hub linking to
/docker/rss, marked "NEW HUB" like VMware's and Ansible's. The hubs were eight copies of one<Link>block, and a ninth tripped Qlty's similar-code check. They are now data,QUICK_ACCESS_HUBS, rendered by one block. Each hub keeps its own classes as whole literal strings, since Tailwind only generates classes it finds written out, and the built page carries the same nine links with the same classes. -
Newsletter. Nothing changed:
newsletter/issue.jshas nothing keyed by provider, so Docker items reach the weekly issue as they are published. -
Docker in the CMS: an article can be written, classified, voiced, illustrated and previewed as Docker (#775).
- Classification. The analysis prompt in
content/inspect.jslists Docker among the providers an article must be filed under, as a service provider. It files Dockerfiles, image builds, Docker Desktop, Compose, Docker Hub, Scout and Sandboxes under Docker, and containers on another provider under that provider (Docker images on Azure Container Apps are Azure, a workflow that builds an image is GitHub, a module that deploys containers is Terraform), the same rule asdockerbeing last inlib/providers.js. - Voice.
content/voice.jshas adockervoice (a container practitioner quoting real Dockerfile stages, compose services and docker commands). - Cover art.
ai-cover.jshas a Docker theme for AI covers.cover-svg.jshas a Docker palette (#1D63ED) for template covers.HERO_PROVIDERSnames Docker on the server and in the admin copy, and a new test holds the two lists to each other.generate-brand-art.mjsgains Docker inPALETTES,COVER_PROVIDERSandHERO_PROVIDERS, andnpm run art:generateproducedpublic/images/default-heroes/docker.pngandpublic/images/docker-hero/1–5.png. Every existing PNG regenerated byte-identical.- The Docker landing page rotates those five images, as Terraform's does.
- Image prompts. The server allowlist in
cms/image-prompts.jsandImagePromptsPageboth name the seven Docker pages (hub, news, blog, code, sandboxes, tools, education), and a test holds the admin page's Docker group to the allowlist. - Submissions and admin.
- Docker is offered on the blog and Coder Corner submission forms, but not on architecture or frameworks, since Docker has no such pages.
- Docker is in the editor list's filter and the image gallery's provider tags.
pageMeta.jsinfers Docker from a URL, last, so a URL about Docker on Azure, GitHub or Terraform stays with that provider.
- Staging preview.
PreviewPagereadsVALID_PROVIDERSinstead of its own copy, which lacked Docker, so a Docker draft now previews as Docker, not AWS. -
CI.
ci.yml's functions row also runs when either of the two admin files the new parity test reads changes. -
Docker education catalogue, checked on 2026-09-29;
/docker/educationis a real Learning page and Docker is on the Learn index (#778). - Docker runs no certification exam of its own. Docker's own list is
docker.com/trainings, and it offers self-guided modules, a YouTube series, and one credential that LinkedIn Learning issues.src/data/docker/education.jssays so as data (DOCKER_RUNS_OWN_EXAM = false). The page says it first, in visitor words (CREDENTIALS_NOTE). - Two credentials, each naming its
issuer, which the card shows as "Issued by …":- the Docker Foundations Professional Certificate (LinkedIn Learning: three courses of about four hours, then a final exam);
- the Docker Certified Associate (Mirantis: 55 questions in 90 minutes, USD 199 or EUR 200, valid two years). docker.com does not list it.
-
Seven learning paths. Six are the modules docker.com links:
- "Build and share a containerized application", 15 minutes. The link on docker.com now redirects here.
- "Building images", Beginner, 25 minutes, five modules.
- Compose.
- Docker Scout.
- Build Cloud.
- The administrator set-up guide.
The seventh is the LinkedIn path. Each title, module list, level and time is the page's own, and nothing is estimated. - Four resources: Docker Training, the documentation, the guides and the Docker Concepts playlist. -
EducationTrackstakes three optional fields. A path may carrylinkLabelandduration, and a certification may carryissuer. Every other catalogue renders as before, which apathMetatest pins. - Learn index.EducationIndexPagelists Docker as a ninth catalogue, and the copy, meta description and table caption say "nine". The table's minimum width is 72rem, up from 64. A test holdsPROVIDER_CATALOGUEStoVALID_PROVIDERS. - No Docker architecture blueprints. #778 asks for them only "if Docker architecture designs are wanted". The Docker hub's three focus areas do not include them, so/docker/architecture-designsand/docker/frameworksstill answer 404, as Terraform's do. -
Docker blog, news and code detail pages; Docker in the content manifest and the public API (#776).
/docker/blogis now the sharedProviderBlogPage("Docker Containers Blog"), listing published articles filed under Docker./docker/codeis the shared code listing ("Docker Code Patterns"), like Terraform's and Ansible's. Both replace the placeholders, and with nothing published each says so the way every provider's does.App.jsxroutes/docker/blog/:slug,/docker/news/:slugand/docker/code/:slugtoBlogDetailTemplate; before this they were the 404 page.dockerjoinsPROVIDERSinscripts/build-content-manifest.mjs, so a Docker article pre-renders at/docker/blog/<slug>and enters the sitemap. That list is now exported and held toVALID_PROVIDERSby a test.ProviderBlogDetailDispatcherandProviderNewsDetailDispatcherare now one membership test againstVALID_PROVIDERSinstead of a branch per provider (ten returns each once Docker was added), and a route test renders every provider's blog and news detail path, so no provider's pre-rendered article can be a 404. The four code pages (GitHub, Terraform, Ansible, Docker) were copies of one body and now sharecomponents/shared/ProviderCodePage.jsx, each passing its provider, title and description.docker: ['Docker', 'docker']joinsPROVIDER_ALIASESinfunctions/src/lib/public-reads.js:?provider=dockernow matches documents filed as "Docker", andlistCertEventsanswersplatform=dockerwith an empty list instead of a 400. The section counts derive their providers from that table, so once Functions is deployed and the manifest next published, the sitemap leaves out Docker sections with nothing in them, as it does for every other provider. -
Docker article series: two how-to drafts for review (#772, #773).
docs/content/blog-docker-01-building-images.md, "Building a lab image you can trust", readslab-image/Dockerfileas the worked example: base images pinned by digest and the workflow step that fails an unpinnedFROM, every download checked againstversions.env, the five stages and therunnerandfulltargets, the provider mirror and the Azure Verified Modules vendored under/opt/avm, uid 65534 with every write under/tmp,smoke.sh, provenance fromactions/attest-build-provenance, and the Docker Hub copy by digest through Docker's OIDC connection with no stored token. It ends withgh attestation verify oci://docker.io/hybridcloudworks/hcw-lab:latest --bundle-from-oci, plus a stricter form pinned to the signer workflow andrefs/heads/main.blog-docker-02-desktop.md, "Docker Desktop for the labs", installs Docker Desktop on Windows (WSL 2 backend, per-user) and macOS, walks the Containers, Images, Volumes and Builds views, sets memory, disk, file sharing and Apple silicon emulation for the lab image, runs the labs' follow-along line in PowerShell and bash, and compares Desktop with the Docker Engine on the Hybrid Lab host. Docker Desktop's current release (4.93.0 of 2026-09-28, Engine 29.8.1) and its subscription terms (the Docker Subscription Service Agreement last updated 2026-08-26, and Docker's licensing page) were checked on 2026-09-29 and are dated in the article. Every output quoted was measured on 2026-09-29 on Windows 11 with Docker Desktop 4.93.0 (WSL 2 backend): thefullbuild,smoke.shpassing offline and refusing with the network on, the digest match between the two registries, bothgh attestation verifyforms and a refused one, the follow-along mount writing asnobody, a uid 1000 mode 755 directory refusing that write, and the bash lines in Git Bash failing (an empty/workspace, andC:/Program Files/Git/workspace/smoke.sh). Not run, and the Desktop article says so: the Windows and macOS installers,wsl --update, the.wslconfigchange, and anything on a Mac. Listed in the docs nav marked "(draft)". Nothing is published; both land In Review only when imported from the Content Queue after merge. -
Lab image: publishing to Docker Hub through a Docker OIDC connection, with no stored token (#779). Owner decision 2026-09-28.
publish-lab-image.ymlhas a third job,Publish to Docker Hub, which runs afterPublish to GHCRand only while the repository variableDOCKERHUB_ENABLEDistrue. It signs in withdocker/login-actionv4.6.0 (the pin the workflow already used; OIDC arrived in v4.5.0), which exchanges the job's GitHub ID token (audiencehttps://identity.docker.com) for a Docker access token that lasts 300 seconds. That needs the connection ID inDOCKERHUB_CONNECTIONand no password. It then copies both images from GHCR todocker.io/hybridcloudworks/hcw-labandhcw-lab-runnerby digest withdocker buildx imagetools create --prefer-index=false, checks that the digest Docker Hub reports equals GHCR's, and attests each image under its Docker Hub name withpush-to-registry. The digest is kept because, measured with buildx v0.37.1, the flag copies the manifest's own bytes, while the default wraps a single image in a new manifest list with a new digest.id-token: writesits on that job andpublishonly. A separate job, so GHCR never waits on Docker Hub: a Docker Hub failure turns the run red after GHCR has published and attested, and "Re-run failed jobs" repeats only the copy. Until the owner creates the connection the job is skipped. The Docker side trusts one subject, this repository'smainin GitHub's immutable-identifier form, with no environment, because an environment subject carries no branch. New runbookdocs/runbooks/docker-hub-publishing.md(the owner's steps, every form value, what success looks like, a failure table, and a one-time manual copy if the organisation's plan cannot create a connection yet); new §4.11 and two §4.2 rows in Required inputs; store 3 rows in Variables and secrets; Docker Hub pull and verify commands inlab-image/README.md, whose GHCR note now records that both packages answer anonymously. The site's "Run it locally" copy and the Docker pages still name only GHCR until a run has published to Docker Hub. -
The repository is licensed under Apache-2.0. Owner decision 2026-09-28, #780.
LICENSEat the root is the Apache License 2.0 text byte for byte ashttps://www.apache.org/licenses/LICENSE-2.0.txtpublishes it (11,358 bytes, LF, SHA-256cfc7749b…c523d30)..editorconfiggives itindent_size = unset, because 24 of its lines are indented by an odd number of spaces and the text is not reflowed.NOTICEnames HybridCloudWorks and carries "Copyright 2026 Saul Patino and HybridCloudWorks contributors". It also says that third-party names, logos and marks (the vendor logos underfrontend/public/icons/providers/andfrontend/src/assets/brands/) belong to their owners and are not licensed under Apache-2.0, and that other third-party material, such as the fonts underfrontend/public/fonts/, keeps its own terms.scripts/validate-repository-structure.ps1allows both files at the root and fails when either is missing. The six npm packages (frontend,functions,scripts,vps-agent,edge/availability-probeandlab-host/coder) declare"license": "Apache-2.0". The five lockfiles carry it on their root entry exactly as npm 11.17.0 writes it: each was regenerated from a scratch copy and matched byte for byte.frontend/scripts/package.jsonis a CommonJS boundary marker, not a package, so it has no licence field. The eleven lab-host Ansible roles saidlicense: UNLICENSEDinmeta/main.ymland now sayApache-2.0. The site footer drops "All rights reserved." and keeps "©Hybrid Cloud Works.", because a blanket reservation on every page contradicted the code licence. The Terms page's content clause, which begins "Unless otherwise stated", is unchanged. The README has a Licence section. The docs site had nothing that conflicted, and mkdocs.yml'scopyright: HybridCloudWorksstays. -
Home: the providers in two rows with a guide under them; Docker as a service provider, with placeholder pages. Owner request 2026-09-28. The home page strip is two evenly spaced rows, Azure, AWS, GCP and VMware, then GitHub and FinOps, a pipe, and Terraform, Docker and Ansible; each label still links to its hub. On a phone row 2 wraps into its two groups (two over three) and the pipe is dropped, with no horizontal scroll at 360 to 414px. Pointing at a provider, focusing it or tapping it shows a frame one blank line below the rows: a paragraph that says what kind of provider it is (cloud, framework or service) and how this site uses it, checked against the repository, with an icon at the bottom-right that opens the provider's website in a new tab (
rel="noopener noreferrer", named "... website (opens in a new tab)"). The frame never changes height: every text it can show is rendered into it invisibly, so it is as tall as the longest at any width, and at 768px and up that is exactly four lines, measured for all nine at 768, 1024 and 1440px in both themes, with the next section's position unchanged. The last provider shown stays shown, a 90ms hover intent keeps a pointer passing over another label on its way to the icon from taking the frame over, the first tap shows and the second follows the link, and the text is a polite live region that neither moves nor holds focus. The copy and types are data infrontend/src/components/home/providerGuide.js, whereproviderGuide.test.jspins the nine providers, rows, types, websites and a 320-character ceiling. Docker joins the provider registry (providers.js, withdockerlast so container writing about another provider stays with it;VALID_PROVIDERS,routeFactory.ts,App.jsx, the pre-render list, the header,--docker-blue#1D63ED and atheme-docker, and a Simple Icons mark, CC0) with a landing page on the Terraform template and three focus areas marked coming soon (building images withhcw-labas the example, the Docker Desktop app, and running an agent in a sandbox, which links to the recipe on/education/labsfor now), plus static Blog, Code, Learning, News and Tools placeholders that read nothing from the API. Eight Docker pages pre-render (196 documents, from 188). The header needed room for a ninth provider: the all-providers list now sizes its columns to their labels, the logo can no longer be squeezed out (it was zero pixels wide on the home page at every desktop width, and a few pixels on the hubs at 1024 to 1280px), the empty box beside the menu button gives its width back fromlg, and the mobile menu scrolls inside itself instead of running its last links off a 740px screen. -
Labs page: Coder is credited beside the intro, with its cursor blinking; the intro no longer says the host is onboarded to Azure Arc. Owner request 2026-09-28. On
/education/labsthe column to the right of the intro was empty on desktop;CoderCredit(frontend/src/components/labs/CoderCredit.jsx) now fills it fromxl, top-aligned, with the intro kept at its 48rem, and stacks under the intro at full width below that (a row on a tablet, a column on a phone; no horizontal scroll at 390 px). It shows Coder's wordmark as inline SVG in the theme's text colour and says "We provide these browser labs using Coder, the open-source platform for self-hosted development environments. Every lab workspace is a Coder workspace built from our lab template.", with Coder linking tohttps://coder.com/(the site's canonical) in a new tab,rel="noopener noreferrer", named "Coder (opens in a new tab)". No sponsorship is claimed. The square that ends the wordmark blinks like a terminal cursor (.coder-cursorinsrc/index.css): a 1 s step blink, five times after load and again while the pointer or focus is on the credit, never underprefers-reduced-motion: reduce; five and not forever because WCAG 2.2.2 asks for a pause control on blinking past five seconds.CoderCreditMediais the one place to swap in a looping mp4 if the owner supplies one. The owner's two logos, cleaned, are kept infrontend/src/assets/brands/coder/, each opening with a comment saying it is Coder's mark, kept only to credit Coder (a comment rather than a README, which the repository policy keeps underdocs/); the test fails if the inline wordmark differs from the file. The intro's "a single server onboarded to Azure Arc" became "a single server whose Azure Arc status is on the live card further down this page", and the estate card's intro stopped asserting onboarding too, since #663 has not run yet; both stay true after it does.CoderCredit.test.jsx(11 tests) covers the link, the names, the cursor class, the two files' attribution and the stylesheet's finite blink and reduced-motion rules;public-copy.test.jsscans the new file and passes the new sentences. -
The lab template is published from the host; lab workspaces open trusted, without the AI chat; a missing template no longer shows Coder's error in a pane. Three things from the first live workspace on 2026-09-28. The publishing steps began with
winget install Coder.Coder, which reported success and installed nothing, so that day the template was published by hand from thecodercontainer. Thecoderrole now installs/usr/local/sbin/hcw-coder-template-push(root:root 0750), and publishing is one PowerShell line with the owner's short-lived token on the clipboard (lab-host/README.md, "Publishing the template"); nothing is installed on the workstation. The helper takes the token from stdin only (never an argument, never printed, never written to a file), copies every*.tf,.terraform.lock.hclandREADME.mdfrom the checkout in/opt/hcw-src, and not the test beside them, into a freshmktempdirectory in the container, runscoder templates push hcw-labandcoder templates edit hcw-lab --default-ttl 1hthere againsthttp://127.0.0.1:7080, checks that one version is active and that Coder reports1h0m0s, prints both, removes the copy on every exit and exits non-zero on any failure. The autostop iscoder_template_default_ttlin the role's defaults, whole hours, asserted by the role and rendered into the helper.roles/coder/tests/hcw-coder-template-push.test.shrenders the helper with the role's defaults and runs it against a stubdockerand a fakecoderin theansible-lint (lab-host)job.bootstrap.shdoes not publish by itself: a token that can publish is as good as root on this host, because the provisioner runs beside the Docker socket, and Coder's API answers it from anywhere (the role's README has the reasoning). Before the template existed, a lab's pane loaded Coder's create page, which answered with Coder's own red "Resource not found" box and a stack trace. The lab launcher now readsGET /api/v2/organizations/default/templates/hcw-lab, the read Coder's create page makes first, once before it frames that page; a 404, a deprecated template or another template's body ends the visit with the site's sentence,Lab workspaces aren't available right now., and no Coder page is loaded.scripts/lab-host-launcher.test.mjs(99 tests) andlab-host-visitor-copy.test.mjscover it. The workspace template gives code-server four User settings through the pinned module'ssettingsinput, checked against module 1.6.0 and VS Code 1.139.1 in code-server 4.139.1:security.workspace.trust.enabled: false(the first workspace opened in Restricted Mode),chat.disableAIFeatures: true(the Chat panel opened asking for a sign-in the site does not offer),workbench.startupEditor: "readme"andtelemetry.telemetryLevel: "off";template.test.mjspins them and both versions. The owner check in "First admin sign-in" runs the CLI in the container as well. - Lab workspaces: the editor opens inside the site's pane. Owner
decision 2026-09-28: the lab is reached only through panes on the site,
and the panes-only lock is not loosened. Coder's dashboard opens
code-server in a new window or tab (
open_inhas no same-frame value in v2.37.3), which the lock turns into the labs page, so the editor could never open in a pane. A lab's pane now loads the lab launcher,https://coder.lab.hybridcloudworks.com/_hcw/lab/?lab=<id>: four static files inlab-host/coder/launcher/(no build step, no third-party code) that thecoderrole installs in/etc/caddy/hcw-lab-launcherand Caddy serves beforehandle @coder. With the learner's own session, GET only and same origin, it maps the lab through a fixed allowlist to one workspace per learner (lab-lzb,lab-tfv,lab-asc; 16 characters at most, socode-server--<workspace>--<owner>fits one DNS label with a 32-character username), reads/api/v2/users/me(a 401 asks the visitor to use Sign in with GitHub above the pane) and their workspace. A missing workspace gets Coder's own create page with its consent dialog, and a stopped one Coder's own workspace page, in a frame of the same origin; no POST, no CSRF token. It waits (one to five seconds apart, ten minutes or six failed reads at most) for the build to run, the agent to be connected and ready, and code-server'shealthto behealthy, checkssubdomain_nameagainst^code-server--[a-z0-9-]+--[a-z0-9-]+$and requires it to be exactly this workspace's name for the signed-in username (/api/v2/users/me), so it can only open the learner's own editor, andlocation.replaces itself to that name under a fixed suffix, so code-server keeps its own origin. It posts{ type: 'hcw-lab', state }to both site names. Caddy adds a strict policy beside the panes-onlyframe-ancestors(default-src 'none'; scripts, styles, fetches, images and frames fromcoder.labonly;base-uri 'none';form-action 'none'),Cache-Control: no-storeandnosniff, and no top-level exemption, so a direct visit still redirects. The role copies the four files by name, removes anything else from the directory Caddy serves, and refuses a launcher directory that is not/etc/caddy/hcw-<name>, since it removes that directory whole while Coder is disabled. On the site,LabPanePage.jsxframes the launcher (frame-src,sandboxandallowunchanged), takes a message only from Coder's origin, the pane's own window and a known type and state, shows the state on the toolbar, makes Sign in with GitHub the main action when the launcher finds no session, and treats any message as the frame loading;unavailableshows the page's own unavailable section. The catalogue gainsworkspaceName, andcatalogue.test.jsand the newscripts/lab-host-launcher.test.mjsboth fail when it and the launcher's map differ; the CI filters now run each side's test when the other side's file changes.scripts/lab-host-launcher.test.mjs(81 tests) drives the logic with a mocked fetch, clock and navigation: the allowlist, the 401 path, create, stopped, starting, the healthy redirect, malformed names and another learner's, the backoff and the cap. It also holds the files (one module script, nothing inline, text only throughtextContent) and the route.lab-host-visitor-copy.test.mjsscans every launcher message and the page's words, and holds itsunavailableto the site's sentence. - The lab panes no longer depend on the status token.
coder-statusnow saysconfiguredwith a validCODER_URLalone andreachablefrom Coder's unauthenticatedGET /api/v2/buildinfo.CODER_STATUS_TOKENadds only the templates and the running count; missing, or refused with 401 or 403, the answer istemplates: []andcapacity.running: null, and the Function App logs a warning naming the setting, never its value. So the owner's first sign-in works in a pane, and the token's one-year expiry can no longer close every lab. The response shape is unchanged. Every call to Coder now refuses to follow a redirect, which Node's fetch would have followed to another origin withCoder-Session-Tokenstill on it.CoderStatusCardthen says only "Coder is reachable." and lists nothing; it had been about to print "0 of 5 workspaces running" and "No lab template is available yet", becausecapacityWordsreadNumber(null)as zero, which also affected the estate card and is fixed there too.lab-host/README.md, "The status token for the site", now makes the owner's CLI token in Coder's own Tokens page inside a pane, namedhcw-setupwith the shortest expiry, instead of copying the session cookie out of the browser's developer tools; thehcw-statusTemplate Admin user and its scoped one-year token are unchanged. -
hcw-vault-setrefuses a key the playbook defines.bootstrap.shpassesvault.ymlwith-e, which outranks every other variable, and the helper took anyvault_*name, including thevaultrole's own (vault_enabled,vault_api_portand so on). It now reads/opt/hcw-srcat run time (HCW_SRC_DIRin tests) and refuses, with exit code 2 and the defining file named, any top-level variable ofgroup_vars/,host_vars/or a role'sdefaults/orvars/, and any name a play, the inventory, or a role's task or handler sets (vars,register,set_factin every spelling); with no checkout, no role, or a file that does not parse, it refuses every key. That catches thevaultrole's task varsvault_seal_statusandvault_state_advicetoo. None of the thirteen keys in use collides. Its Python now runs isolated (-I), so ayaml.pyin the directorysudoran from can no longer be imported as root.hcw-vault-set.test.shgrows from 39 to 80 checks, and fails without-I. -
Coder is on for members of the HybridCloudWorks GitHub organisation only, and the vault helper is under Ansible. Owner decisions 2026-09-28: turn Coder on; only members of
HybridCloudWorksmay sign in; the owner signs in first and becomes Coder's owner, and nothing here creates a Coder user.lab-host/ansible/group_vars/all.ymlsetscoder_enabled: trueandcoder_oauth2_github_allowed_orgs: [HybridCloudWorks]; the threevault_coder_*keys were already in the host's vault (checked by name). The Compose file already held what the live host needs (PostgreSQL 18.6 at/var/lib/postgresql, Coder v2.37.3 by digest,CODER_REDIRECT_TO_ACCESS_URL=falsefrom #723, password sign-in off, noCODER_FIRST_USER_*), as did #750's route. One setting is added:CODER_DISABLE_PATH_APPS: "true". Coder v2.37.3 otherwise serves every workspace app on the dashboard's origin too, at/@<owner>/<workspace>/apps/<app>/, where a workspace's JavaScript can call the Coder API. Coder's own guidance is to turn that off whenever a wildcard access URL exists, and the template's code-server is a subdomain app.template.test.mjsnow fails without it (11 tests). A newvault_toolsrole, straight afterhardening, makes/etc/hcw/ansibleroot:root 0700 and installs/usr/local/sbin/hcw-vault-set(root:root 0750), which had been put on the host by hand. The helper sets onevault_*key from stdin without printing it. The role's copy fixes two defects in the hand-installed one.installunlinkedvault.ymlbefore writing its replacement (seen with strace on 26.04's uutilsinstall), so a run stopped in between lost the vault; the copy is now written beside it and renamed over it. And a leading byte order mark on the piped value was kept, becausestr.strip()leaves U+FEFF. The first run after merge reports that taskchanged.roles/vault_tools/tests/hcw-vault-set.test.sh(39 checks, run as root in theansible-lint (lab-host)job against a scratchHCW_VAULT_DIRwith the job's ownansible-vault) sets two keys and replaces one. It refuses an empty value, seven bad key names, a missing password and a wrong one, each time leaving the vault byte for byte. It checks the file is$ANSIBLE_VAULT;1.1;AES256ciphertext, root:root 0600, that YAML-like values come back as the same strings, and that nothing temporary remains. Against the hand-installed copy it fails the two BOM checks.lab-host/README.md, "The vault", now sets keys with one PowerShell line per key. You paste the line, copy the value, then press Enter; the PostgreSQL password is made on the host and never shown. With Coder on, a first run without a vault now stops at thecoderrole, afterhcw-vault-setis installed. The README, the reinstall runbook andbootstrap.sh's header say so. The Coder section's status-token steps were measured on Coder Community v2.37.3. The token is scopedtemplate:readandworkspace:read, minted by the owner withcoder tokens create --userfor a user of its own,hcw-status. That user holds Template Admin, the only role below Owner that counts other users' running workspaces (Member, Auditor and User Admin answeredcount0). It is a GitHub user at a.invalidaddress, because--login-type noneneeds Premium service accounts in v2.37 and password users are refused while password sign-in is off. Unscoped, the same user's token deleted a template; scoped, it got 403. The owner runs the steps from the workstation throughssh hcw-laband thecodercontainer's CLI, and the token goes to the clipboard, never the screen, before it is seeded at https://hybridcloudworks.com/admin/integrations?tab=keys. The site's panes open only once the card reads reachable (#758), so the owner's own first token is the GitHub sign-in's session, copied from the browser's developer tools. Turning Coder on in the container rehearsal also surfaced a latent defect.coder_workspace_memory_mibandcoder_server_memory_reserve_mibhad defaults only in the role's argument spec, which Ansible never applies, so the first run withcoder_enabled: truestopped at the capacity assertion with'coder_workspace_memory_mib' is undefined. Both are in the role's defaults now.lab-host/ansible/check-argument-spec-defaults.py, in the same CI job, fails on any option across the 11 roles whose default is written only in its spec, or whose default disagrees with it. What a pane can show from the lab host now speaks to visitors. Coder stopped answersLab workspaces aren't available right now., the site's ownUNAVAILABLE_SENTENCE, and the apex and unclaimed names answerThere's no lab at this address.The fail-closed HTTP-only 503 keeps naming the missing vault key for the operator, because anhttp://page cannot render in the site's pane.scripts/lab-host-visitor-copy.test.mjsscans everyrespondbody in the three Caddy templates for tool, host and setting names. It pins the Coder answer to the pane's sentence and keeps the operator body on the HTTP-only site. -
Azure Arc onboarding of the lab host is two owner commands:
scripts/lab/Register-LabArc.ps1, then the same with-Connect(#663). The runbook's ten manual steps (a year-long secret fromcreate-for-rbac, a hand-edited vault, a pull request to fliparc_enabled, fourazcommands for the monitoring, and a manual deletion afterwards) become one run before thehcw-azureapply and one after it. The first run resolvessub-app-site-prod-cusby name and checksrg-lab-hybrid-prod-cusexists. It reads the host over ssh (agent state, arc fact, the vault helper, and whichvault_arc_*keys the vault holds, by name only), finds or creates the single-tenantsp-arc-onboarding-lab-hybrid-prod-cuswith no role and no credential, and grantsid-plat-terraform-prod-cus-01Resource Policy Contributor on the group only, so the audit policy can be applied. Then it mints a 24-hour client secret withaz ad app credential reset --append --end-dateand pipes it from a variable into/usr/local/sbin/hcw-vault-setover ssh's standard input with its three identifiers. The value is never printed, written to disk on the desktop, or put on a command line, and it is written as exact bytes with a bare line feed, because a PowerShell pipe on Windows would add a carriage return. The run reads the vault back and deletes the new secret from Entra again if the vault does not hold it. Finally it prints the service principal's object id, the twohcw-azurevariables (arc_onboarding_principal_id, HCL off;lab_hybrid_policy_enabled, HCL on, which ADR 0032 decision 3 and #663's acceptance both need) at the workspace's variables page, and the plan to expect,Plan: 5 to add, 1 to change, 3 to destroy. The onboarding role assignment stays Terraform's; the script only reads it.-Connectchecks that grant (read only) and stops if a machine resource from an earlier host is in the way. It keeps or re-mints the secret, writes the host's arc fact, runsbootstrap.sh, and waits for Connected. It then deletes every client secret on the registration and all fourvault_arc_*keys, with a decrypt, drop, verify, re-encrypt and compare rewrite on the host that leaves every other key byte for byte, and reads both back empty. Last, it installs the Azure Monitor Agent extension and associatesdcr-lab-hybrid-prod-custhroughaz rest(Hybrid Compute API 2025-01-13, Monitor 2022-06-01), so no CLI extension is needed. When connect fails it removes the fact it wrote, so later runs are not stopped at the role. Both runs are idempotent and take-WhatIf.arc_enablednow comes from the host, not the repository:group_vars/all.ymlreads the local fact/etc/ansible/facts.d/hcw_arc.factand is true only for"enabled": true. A rebuilt host is not onboarded whatever the repository says, and a repository-widetruewould stop its firstbootstrap.shrun at the role's fail-closed vault check, before every later role. Checked on a systemd Ubuntu 26.04 container with the pinned ansible-core 2.21.4. With no fact, the argument spec validated and all 17arctasks were skipped. With the fact, the role installed and heldazcmagent 1.68.03532.1399, readDisconnectedand stopped at its vault check. A missing, non-JSON,"enabled": "yes"or list-shaped fact reads as false.scripts/lab/Register-LabArc.Tests.ps1adds 126 Pester tests. They test the Entra, Azure and host steps against mocked seams, the secret never appearing in output or errors, and the vault helper receiving exactly the value and one line feed. The host scripts run under bash against stand-inansible-vaultandazcmagent, and the whole script runs, with and without-WhatIf, against stand-inazandsshthat refuse every write.docs/runbooks/labs-host.md"Arc onboarding",lab-host/README.md, thearcrole, and Required inputs §4.1 and §4.7 now describe this procedure. The script has not yet run against the tenant or the host. That is the owner's step. -
Lab workspaces open as panes on the site (#751). Owner decision 2026-09-28: the lab is reached only through panes on the site. #750 made the lab host send every top-level visit, Coder's included, back to
/education/labs, so the cards' "Open in Coder" links had become a loop. Each lab now has a page,/education/labs/<id>(frontend/src/pages/shared/LabPanePage.jsx), that frames the workspace deep link/templates/hcw-lab/workspace?mode=auto¶m.lab=<id>, checked against thehcw-labtemplate's name and itslabparameter. The card button, now "Open lab workspace", is an in-site link to it. The frame'ssandboxisallow-scripts allow-same-origin allow-forms allow-popupsand itsallowgrants clipboard read, clipboard write and fullscreen to Coder's name and its workspace apps' wildcard, each justified in the page. The toolbar makes the pane itself full screen and goes back to the labs page.staticwebapp.config.json'sframe-srcgains exactlyhttps://coder.lab.hybridcloudworks.comandhttps://*.coder.lab.hybridcloudworks.com;connect-srcis unchanged, andcsp.test.jsholds both. GitHub cannot be framed, so sign-in runs in a tab of its own: "Sign in with GitHub" opens Coder's/api/v2/users/oauth2/github/callback, the one path #750 lets through at the top level and where Coder v2.37.3 starts sign-in when nocodeis present. Coder's closing redirect is a top-level visit that #750 sends to/education/labs, which sends the tab on to the lab's pane (components/labs/labSignIn.js) and, through astorageevent, reloads the pane in the first tab. The site records only that a sign-in started and finished, in localStorage, never a credential. The pane opens only whenGET public/labs/coder-statussays Coder is configured and reachable, because a cross-origin frame cannot report an error page; every other answer, and a frame that has not loaded in 30 seconds, reads "Lab workspaces aren't available right now." Visitor wording names no tool behind the site, and #755's guard now scans the new files and passes the new sentences. The three pane pages are pre-rendered from the catalogue, like the certification pages. ADR 0032 records embedding Coder, rejected when it was accepted, as the chosen approach (amendment "Coder in the site's panes"), anddocs/architecture/labs-host.mdsays what the site side now does. Found while building it and recorded there: Coder opens a workspace app such as code-server in a new window or tab, which #750 redirects, so code-server does not yet open inside the pane. Frontend tests 3,058 to 3,111 (201 to 204 files), on Node 26. -
The lab host holds every pinned job image before a job needs it, and removes the digests no pin names. The first public "Validate on the lab" job on the live host (2026-09-28) spent its opening seconds pulling the
hcw-lab-runnerimage, about 320 MB, while the visitor waited and the job log filled with pull lines. A newlab_imagesrole runs straight afterlabs_agentinlab-host/ansible/site.yml. Its plan,roles/lab_images/files/lab-images.mjs, runs on the host's Node.js and importsIMAGESfromvps-agent/lib/capabilities.jsin the playbook's checkout and the agent's, so the list is the module itself and a pin bump needs no second edit. It also reads the Coder workspace image from the template'simageandimage_digestlocals, which is pulled whilecoder_enabledis true and kept either way. A pin that is notname[:tag]@sha256:<hex>fails the play naming the entry, and so does a pull that fails. Pulls arecommunity.docker.docker_image_pullbyrepository@digestwithpull: not_present, so a present digest isok; the tag is dropped because the module splitsname:tag@digestat the@and would pull on every run. Removal isdocker image rmby reference, because that module'sdocker_image_removeanddocker_imagerebuild a digest reference asname:sha256:..., which Docker 29.8 refuses withinvalid reference format. It covers only the pins' repositories andghcr.io/hybridcloudworks/hcw-lab*, never lists a reference in any other repository, and keeps an image a container still uses until a later run. The runner passes no--pull, sodocker runuses a present digest without a registry call.scripts/lab-images.test.mjs(19 tests) holds the helper to both files as they are and the plan to those rules. Verified in a privileged systemdubuntu:26.04container, from a bundle of the branch, on Docker 29.8.1's containerd image store: the first run pulled both capability images and the second waschanged=0, with exactly the two pinned digests in the lab repositories. A test commit then movedIMAGES.alpineto 3.24.1.--check --diffreported the one pull and changed nothing. The real run pulled the new digest and kept the old one, which a stopped container used. Once that container was gone, the next run removed the old digest's lab references and left an unrelated tag on the same image in place, and the run after waschanged=0. With the test host cut off from the network, ashell-echoand aterraform-validatejob run through the agent's ownrunInDockerpassed in 403 ms and 577 ms, while a pull of an image the host did not hold failed as unreachable. -
Admin → Labs: remove a deactivated agent's registration. Owner request 2026-09-28: the VPS reinstalled on 2026-09-26 came back as
vps-hostinger-01, and the old host's record,srv939861(v0.1.0, last seen 2026-08-20), stayed on the Agents tab, Offline and Deactivated, with no way to take it off: #740 built register and activate/deactivate only, and the owner holds no data-plane role.DELETE /api/cms/labs/agents/{agentId}joins the PATCH on that template's onehttpRouteByMethodregistration inlabs-http.js, at the same editor role (lib/labs/agent-registry.js). It reads no body; answers 404 for an unregistered id, so a second DELETE is a 404; refuses an active agent with 409 "Deactivate it first"; and refuses with 409, naming the jobs, an agent that still holds one inlab_jobs(byagentId, which the claim writes):running, orclaimedinsideCLAIM_LEASE_MS. A claim older than the 15-minute lease is not counted, because the claim path already hands it to the next agent and a deactivated agent could never finish it; counting it would let one stranded job block the removal forever. Then it deletes thelab_agentsdocument and writes oneadmin_audit_logsrow,lab_agent_removed, with the removed document'sagentId,oid,lastSeenAtandversion. Without a document the agent guard refuses the agent asno-agent-record, the state before its first registration, and Register agent adds it back. On a deactivated card only, Remove opens the repository'sConfirmModalnaming the agent and saying its registration is deleted and it can be registered again later; confirming sends the DELETE, toasts and refreshes. The Labs snapshot'srefreshno longer drops a request that arrives while a poll is in flight: it reads once more after it, so the card goes when the toast appears rather than coming back from a read sent before the delete. The route inventory now pins both verbs oncms/labs/agents/{agentId}torequireRoleat editor and neverrequireAgent;.azure/api-surface.jsoncarries the route; the Labs host runbook gains step 8 of "Reinstalling the host", retiring the old agent record. -
"Validate on the lab" opens to the public, locked to the site's pane by origin and Cloudflare Turnstile (ADR 0032 decision 6 revised). Owner decision 2026-09-28: "The lab should only be accessible through 'panes' from my site, lock to that." ADR 0032 gains a dated amendment replacing decision 6's Gate with a lock, and every bound #672 built stays exactly as it was:
terraform-validateonly, 64 KB, 2 an hour per client, 50 a day, refused while more than 20 are queued or no agent is heartbeating, jobspublic: truewith a one-day TTL. The lock, infunctions/src/lib/labs/public-lock.js, runs before any store read or counter: aPOST /api/public/labs/submitwhoseOriginis not exactlyhttps://hybridcloudworks.comorhttps://www.hybridcloudworks.com(the CORS allowlist's production origins, now exported fromlib/auth/cors.js; both serve the site) is403 ORIGIN_NOT_ALLOWEDbefore its body is read, a missing Origin included; one without aturnstileTokenis403 TURNSTILE_REQUIRED; the token goes to Cloudflare's siteverify with the secret and the addressclient-identity.jstrusts (newtrustedClientIp:CF-Connecting-IPonly on a request carrying the origin secret), and anything butsuccess: truefor one of the site's hostnames and thelab-validateaction is403 TURNSTILE_FAILED, which is how a replayed single-use token fails; siteverify unreachable, past 5 seconds, non-2xx, non-JSON or refusing the secret is503 TURNSTILE_UNAVAILABLE. Before siteverify, an in-memory limiter per instance allows one client ten checks in ten minutes and answers the next429 TURNSTILE_RATE_LIMITED, so junk tokens cannot drive a Cloudflare call per request (from the change's security review, which found nothing above Low). With no secret, or an unresolved Key Vault reference, the POST and the status read answerTURNSTILE_NOT_CONFIGURED, so the status read now says which of switch, Turnstile or agent is missing. The token, the address and the secret are never logged or written to the job. On the builder, the Turnstile widget (Managed mode, shown only when Cloudflare wants the visitor to act) loads only once the lab is open (frontend/src/lib/turnstile.js,landingZone/useLabTurnstile.js); each submission spends one token and asks for the next, and a build withoutVITE_TURNSTILE_SITE_KEYkeeps the button disabled with its reason. The CSP grantshttps://challenges.cloudflare.cominscript-srcandframe-srcand nowhere else, whichcsp.test.jsholds exactly. Terraform addslabs_public_submission_enabled(defaulttrue, the kill switch when setfalse) asLABS_PUBLIC_SUBMISSION_ENABLED, andTURNSTILE_SECRET_KEYas a Key Vault reference toTURNSTILE-SECRET-KEY, catalogued on the API-keys page and listed inEXPECTED_UNRESOLVEDuntil it is seeded. The widget is not a Terraform resource: the pinned provider'scloudflare_turnstile_widgetkeeps its secret as a read-only attribute in state and needs an account-scoped token, so the owner creates it in the Cloudflare dashboard, and a test fails if the resource appears. Both plan differences are declared inscripts/assert-expected-plan.mjs. Required inputs, Variables and secrets, architecture §5.3, the API surface and the labs-host runbook ("Opening 'Validate on the lab' to the public") carry the new values and the owner's four steps. The privacy policy (frontend/public/privacy-policy.html, effective date now 28 September 2026) gains one line saying the builder runs Cloudflare Turnstile while Validate on the lab is available, linking Cloudflare's Turnstile Privacy Addendum. -
Import
docs/contentdrafts into the CMS review queue, never published (#749). Nothing imported a repository draft into the CMS, so reviewing the three lab articles (#737/#744) on the site meant pasting each one by hand. On Admin → Content Queue, Import drafts from the repository lists theblog-*.mdfiles onmain(GET /api/cms/content/import-repo/candidates, the public GitHub contents API, the two contract docs excluded) with what each already is on the site; ticking some and importing (POST /api/cms/content/import-repo,{ paths }, editor) lands each one In Review with a toast, and the queue switches to that filter. Paths must match^docs/content/blog-[a-z0-9-]+\.md$or the whole request is a 400 before any fetch; files are read only fromraw.githubusercontent.com/HybridCloudWorks/HCW-HybridCloudWorks/main/withredirect: 'error', one 8 s deadline over headers and body, and a 256 KB cap. The front matter is read by a port oftooling/workflow.py'sfrontmatter()over the seven keysblog-template.mddefines, and the body goes in untouched, so thelanding-zoneembeds survive. New drafts are written throughcreateContentDocument(thecreateContentItempath) withcontentStatus: 'in_review',Live: false,source: 'repo'andrepoPath/repoCommitSha/repoContentSha256, under an id derived from the path and create-only, so a double import cannot duplicate. Re-importing refreshes the same document only while it is in review (an unchanged file writes nothing), under the read's ETag; an approved, edited, rejected or live article is refused and not touched, as is a new draft whose title a published article already carries. Oneadmin_audit_logsrow per path.slugis deliberately not stored: the slug-holders probe is not status-filtered, and a first publish assignsslugify(title)anyway. The three lab drafts are read from disk by the tests and must parse, and CI'sfunctionsrow now watchesdocs/content/blog-*.md. -
Register a lab agent from the admin UI;
Register-LabAgent.ps1finishes the go-live (#740). The API admits a lab agent only whenlab_agents/{agentId}binds it to its service principal, and nothing could write that document: no route, a read-only Labs page, and a Cosmos firewall that admits only the Function App. So a registered agent authenticated and was refused withAgent access required. Two routes infunctions/src/lib/labs/agent-registry.js, registered inlabs-http.js, both editor or above (the level of the other Labs writes; gate 1, theLabAgentapp role, stays an Entra grant only a tenant administrator can make):POST /api/cms/labs/agentstakes exactly{ agentId, oid, jobTypes? }, any other key a 400, withagentIdthe certificate CN shape,oida GUID stored lower-cased because the agent guard compares it exactly with the token's lower-case claim, andjobTypesa non-empty subset ofLAB_JOB_TYPES(default all five). It creates the document the guard and the claim path read,{ id, agentId, oid, active: true, capabilities, registeredAt, updatedAt, updatedBy }, or patches only the registry fields of one that exists, so a heartbeat is never rewound; the same values again write nothing, and a deactivated agent stays deactivated.PATCH /api/cms/labs/agents/{agentId}takes{ active }and takes effect on the agent's next call, since the guard does not cache the registry. Every write leaves anadmin_audit_logsrow (lab_agent_registered,_updatedwith the previous object id,_activated,_deactivated), and nothing secret is accepted or stored.getLabsSnapshotnow carries each agent'sactive,oidandregisteredAt. On Admin → Labs → Agents, Register agent takes the agent id and the object id with every job type ticked, and says in one line thatRegister-LabAgent.ps1prints both; each card shows its bound object id and a Deactivated badge, and has Deactivate (which asks first) or Activate. Tests prove a registered document passesrequire-agentfor a token carrying that object id, reaches the claim path with its job types, and is refused once deactivated.Register-LabAgent.ps1no longer prints JSON and exits 2: no owner script can get the admin's delegated token the route needs, so step 4 prints https://hybridcloudworks.com/admin/labs?tab=agents with the two values to paste and waits for Enter, then continues to the vault andbootstrap.shand restarts the agent so the verdict reads a clean journal. It skips the wait when the agent is already heartbeating on a service principal it did not just create, so a second run still changes nothing without asking. Docs: the Labs host runbook ("The lab agent's go-live"),lab-host/README.md("The agent identity") and Required inputs §4.7. -
Landing Zone Builder: "Validate on the lab", and public lab submission built inside ADR 0032 decision 6's bounds, closed (part of #672). Three anonymous routes in
functions/src/lib/labs/public-submit.js, registered inlabs-public-http.js:POST /api/public/labs/submitqueues oneterraform-validatejob;GETon the same route says whether a POST would be taken ({ configured, open, code, reason, bounds }, cached a minute);GET /api/public/labs/job?jobId=returns one public job's status and output. All three are closed: unless the Function App settingLABS_PUBLIC_SUBMISSION_ENABLEDis exactly"true", which nothing sets, they answerPUBLIC_SUBMISSION_CLOSEDbefore reading the body, the caller or the store. ADR 0032 still holds anonymous submission Gated, and opening it is the owner's revision of decision 6, then that one setting. Open, the bounds are decision 6's and no wider:terraform-validateonly (400 for any other type), a 64 KB payload (413 above 65,536 bytes), 2 an hour per Cloudflare-verified client throughenforceSubmissionQuota(429), 50 a day globally through a compare-and-increment counter intool_service_cache(503 until the next UTC day), refused while more than 20 jobs are queued (503LAB_QUEUE_FULL), and jobs writtenpublic: truewith a one-dayttl. It also fails closed while no agent registered forterraform-validateis heartbeating (503LAB_AGENT_OFFLINE, nothing counted, nothing queued) and when the lab cannot be read (503LAB_STATUS_UNAVAILABLE). The job read servespublic: truedocuments only, with one identical 404 for a missing, admin or expired job, and never returns the payload, the agent or the requester. The queue ceiling is a read before the write, so simultaneous submissions can overshoot it by the ones in flight together; both counters are exact. The explain route's daily counter moved tolib/daily-cap.jsso both routes share it, behaviour unchanged. On/tools/landing-zone, "Validate on the lab" sits under the generated files. It asks the status route on mount and stays disabled, with one line saying why and no spinner, until the lab is open. It sends the files unchanged, as a gzipped tar the agent's own parser unpacks into exactly the download, because the capability rewrites registry sources to the vendored copies inside the job (ADR 0032 decision 5). Before sending, it predicts that rewrite with the runner image's own rule (lib/landingZone/labImage.js, held tolab-image/Dockerfileandversions.envby a test) and refuses a build calling a module the image does not vendor. Today that isavm-res-network-virtualnetwork0.22.2, which every spoke calls, so only a build without the identity, corp and online spokes can be validated on the lab until lab-image vendors it. The frontend CI row now also runs on changes tolab-image/Dockerfile,lab-image/versions.envandvps-agent/lib/docker-runner.js, which those tests read. -
scripts/lab/Register-LabAgent.ps1: the lab agent's go-live is one owner command (#739). It was several owner steps across three documents, one of them pointing at a runbook row that no longer existed. The script, run withazsigned in to the tenant: checks the tenant and prints theaz loginline when it is wrong; reads/etc/hcw/labs-agent.crtoverssh hcw-labinto a temporary file, with the host checking it against the private key and reporting only whether they match; creates or finds the single-tenant app registration and service principalsp-labs-agent-lab-hybrid-prod-cus-01; appends the certificate withaz ad app credential reset --cert --append, ending one second before the certificate does (without--end-dateaz ends it a year from now, a year before the 730-day certificate); assigns theLabAgentapp role on the HCWSite API, the one grant the agent guard checks; writes the fourvault_labs_agent_*keys into/etc/hcw/ansible/vault.ymlon the host; runsbootstrap.sh; and readssystemctl is-active hcw-labs-agentand the journal to say whether the agent is heartbeating, or which gate refuses it. The vault merge runs as root on the host: decrypt to a root-only temporary directory, replace only those four keys, refuse unless every other key is unchanged, re-encrypt with the existing password file, decrypt again and compare, rename into place, shred. It createsvault.ymlwhen there is none and does not rewrite it when nothing changed; it prints key names, never a value from the file. Re-running changes nothing,-WhatIfchanges nothing, and-NextCertificateregisters a rotated certificate before the swap.LABS_AGENT_API_BASEis nowhttps://api-azure.hybridcloudworks.com/apiinlab-host/README.mdandvps-agent/.env.example: the origin lock refuses the lab host at theazurewebsites.nethost, and the script probes the Cloudflare path from the host first, naming a Bot Fight Mode challenge if it meets one. The API's second gate, thelab_agents/vps-hostinger-01registry document, has no write path (no route, no admin page, a firewall that admits only the Function App), so the script prints the document the API needs and says so. Pester tests cover every path that needs no tenant or host, and run the vault merge under bash in CI;scripts/lab-job-types.test.mjskeeps the document's job types in step with the server and agent allowlists. Docs:lab-host/README.md("The vault", "The agent identity", "Rotating the agent certificate"), Required inputs §4.7 (which also now recordsportainer_enabledandvault_enabledastruesince #729 and the host as provisioned) and the Labs host runbook, "The lab agent's go-live". -
Lab article series: three how-to drafts for the owner's review (part of #677).
docs/content/blog-lab-01-landing-zone.mdbuilds management groups, policy, management and a hub in the Landing Zone Builder and reads what each is for;blog-lab-02-one-container.mdpullsghcr.io/hybridcloudworks/hcw-lab, runsterraform initunder--network noneand explains the provider mirror, the unpacked layout and the module rewrite;blog-lab-03-agent-explains.mdopens the zip in thelab-image/sandbox-templateDocker sandbox and reads whyAGENTS.mdforbidsapply. Each carries onelanding-zoneembed of the same build,lz=mg,policy,mgmt,hub&corp=0&online=0, checked againstdecodeLzto decode to exactly those four components and to be its own canonical encoding, and each ends with "Explain this component" and a link to/education/labs, with the Coder workspace described as coming. Every output quoted was measured on 2026-09-27 with Docker 29.8.0 against the builder's emission for that build: the public image pulled with no login,terraform version(1.16.4), plaininitfailing at the module registry offline,hcw-terraform-validatepassing offline and under the job runner's full sandbox flags (108 KB.terraformagainst 31 MB online), and the sandbox template built and runninginit,fmt -checkandvalidate. Thesbxcommands are the recipe README's and were not run. Found while measuring: a build with any landing zone does not init offline in the lab image, because the spokes callavm-res-network-virtualnetwork0.22.2 and the image vendors only 0.15.0; part 2 says so. Listed in the docs nav marked "(draft)"; publishing and the newsletter stay the owner's. -
ADR 0032 (the learner labs platform) is Accepted, 2026-09-27. Owner approval: "all have been approved to move forward". Its decision 6 is unchanged, so anonymous public lab submission stays Gated until the owner revises that section; the status line and the decisions index say so.
-
ElevenLabs: the last sample replays free, and a new render asks first (owner request 2026-09-27). The owner spent two live checks (257 credits each, 9,486 left on the free plan) only to test playback, because the Audio tab showed a player only for a check run in the same session. Every stored sample is now recorded in
admin_config/podcast_last_sample: the media URL with its?v=version, both voice ids (with names when the voice listing is cached; nothing is listed to find them), the characters billed and the time.GET cms/podcast/elevenlabsreturns it aslastSample(ornull, withlastSampleErrorwhen the read fails), after a HEAD confirms the blob is still there. A sample stored before the record existed, such as the 2026-09-27 render, is still returned (recorded: false), from the blob, the newestpodcast:sampleusage row, and the saved voices when they were saved before it. The card's Last sample section plays it on every load with its date and voices and says "Replaying costs nothing"; a replay never reaches ElevenLabs. Run live check is now Render a new sample (about 257 credits) and opens the adminConfirmModalfirst. The prompt names the credits left and the free replay, and says when the last sample already used the saved voices. Cancel sends nothing. The Podcast voices picker adds "Previews are ElevenLabs's samples and cost no credits".useSettingalso returnssaved, the stored value, which unsaved edits do not change. ADR 0029 §2a carries a dated note. -
Podcast voices: the owner picks two ElevenLabs voices the plan allows, by ear, on the Audio tab (#725). The first free-plan live check was refused with 402
paid_plan_required("Free users cannot use library voices via the API"). The code's Sarah and Aria are library voices to an account created after March 2026. The ElevenLabs card gains a Podcast voices section: a select per host (Maya, Elena) and a list of every voice the key may use, each with its gender, accent and age labels and a play button. A voice the plan does not allow is shown disabled with the reason, never hidden. Two new editor routes back it.GET cms/podcast/elevenlabs/voiceslistsGET /v2/voicesthree times with the documentedvoice_typefilter (default,non-community,community), paginated and cached for five minutes, and marks a library voice unusable on the free plan.GET cms/podcast/elevenlabs/voices/{voiceId}/previewproxies one voice's preview. It takes an id, never a URL; it fetches onlyhttps://storage.googleapis.com/eleven-public-prod/…from the key's own listing, without the key and without redirects; it stops at 2 MiB and passes on only MP3 bytes. The page plays the preview with Web Audio, so the CSP is not widened. The choice is stored asadmin_config/podcast_voicesthrough the Platform settings store (podcast-voices), validated as two different 20-character ElevenLabs ids, and every podcast render reads it first: episodes and the live check. The podcast no longer readsLISTEN_AND_LEARN_VOICE_*, and Listen & Learn is unchanged. The code has no default voice, because ElevenLabs's docs name no id a free account made after March 2026 can use through the API. So a render with nothing chosen refuses before sending, with "Choose the podcast voices first", and the live check waits for a saved pair. A 402paid_plan_requirednow says "Choose voices your plan allows" and names the picker. The key needs a third permission, Voices → Read (voices_read), which the secret catalogue anddocs/standards/required-inputs.mdnow list. ADR 0029 §2a carries a dated amendment. -
Portainer and HashiCorp Vault are on for the lab host (owner decision 2026-09-26).
portainer_enabledandvault_enabledaretrueinlab-host/ansible/group_vars/all.yml, so the nextbootstrap.shrun starts Portainer Business Edition 2.45.1 on127.0.0.1:9443and Vault 2.1.1 on127.0.0.1:8200, both loopback-only. Vault comes up uninitialised and sealed; the first administrator, the licence key and Vault's unseal keys are the owner's steps indocs/runbooks/labs-host.md. -
The lab host refuses a host it was not prepared for; Portainer and HashiCorp Vault join it, loopback-only (owner decision 2026-09-26; #656). The owner's first
bootstrap.shrun on the VPS met a host that had never been reinstalled (two self-hosted runners, Portainer, nginx on :80, k3s, Vault, an old agent install). Before it failed at the agent's checkout it had upgraded and restarted Docker, killing a running Dependabot job, enabled ufw and rewritten sshd's settings. The owner decided to reinstall the VPS clean and to keep Portainer and Vault.bootstrap.shnow examines a host it has never accepted, before it changes anything: any Docker container, an installedactions.runner.*unit, Kubernetes units or directories, a foreign checkout at/opt/hcw-srcor/opt/hcw-labs-agent, anything else under/opt, and any TCP listener but sshd's and systemd-resolved's. On a finding it refuses with the list, exit code 3, and the two ways on (reinstall, orHCW_ADOPT_NONEMPTY_HOST=1). The first run that passes writes/etc/hcw/bootstrap-host-accepted, every completed run records its commit there, and later runs skip the check. A newportainerrole runs Portainer Business Edition 2.45.1 (LTS, by index digest) under the 3 Nodes Free licence. It has a named volume and the Docker socket, and HTTPS on127.0.0.1:9443only, which the role refuses to move; there is no Caddy route, and the owner reaches it through an SSH tunnel. A newvaultrole runs HashiCorp Vault 2.1.1 host-native under systemd, with raft storage,127.0.0.1:8200and:8201only, and TLS from a host-generated certificate. Its download is checked against HashiCorp's GPG signature on SHA256SUMS, with the key pinned by checksum. It never initialises or unseals Vault; those are owner steps whose keys go to the password manager only. This Vault holds lab-host secrets only; production secrets stay inkv-site-prod-cus-01. Both roles are off by default.scripts/version-floors.jsongains avaultkind from endoflife.date'shashicorp-vaultproduct, and records Portainer as unsourced (endoflife.date has no product for it). ADR 0032 carries a dated amendment. The runbook gains the host check, the reinstall procedure, the Portainer tunnel (including the setup token 2.45.1 prints) and Vault's initialise and unseal steps. Auto-unseal through Azure Key Vault and the Arc identity is the follow-up, #726. -
ElevenLabs on the free plan: a credit pre-flight, a non-commercial publish guard, and the account on the Audio tab (#432). Owner direction 2026-09-26: test the podcast voice on ElevenLabs's free plan, 10,000 credits a month at one credit per character on Eleven v3, where one episode is nearly the month.
speech/elevenlabs-account.jsreadsGET /v1/user/subscription(cached in memory for 15 s per key). Before the first dialogue request,synthesizeWithElevenLabsrefuses a job the credits left cannot cover in full, sends nothing, and says "ElevenLabs has N credits left of M, this episode needs K; the allowance resets on" with the quota_exceededcode. An unreadable account (a key withoutuser_read) fails closed. A 402paid_plan_required(a paid-only voice) is no longer reported as out of credit. Owner decision the same day, "Test only, block publish": the podcast records the plan each render ran on (speechTier,speechFreePlan), and approving a transcript whose audio ElevenLabs rendered on the free plan answers 409, naming the non-commercial licence and the upgrade, and writes nothing. The retry route refuses the same way, and the publish job records afree_plan_licenceskip. Paid-plan renders publish as before. New editor routesGET cms/podcast/elevenlabs(plan, credits used of limit, reset date, the last render's billed characters, or "Not configured") andPOST cms/podcast/elevenlabs/sample(a fixed two-turn, 257-character live check through the real provider, usage sourcepodcast:sample) back a Podcast voice card on the Audio tab. ADR 0029 §2a is amended with the date and the rule, anddocs/standards/required-inputs.mdgives the key's two permissions, its credit limit and where to seed it. The voices are unchanged, but their status is recorded: Sarah is a Default voice that expires 2026-12-31 and exists only on accounts created before March 2026, and Aria is a Legacy voice the API reroutes to Zoe. -
PostgreSQL has a version floor, and the weekly job proposes general releases only (#715). Owner instruction 2026-09-26: the newest general release of PostgreSQL.
scripts/version-floors.jsongains apostgresqlkind: line 18, newest 18.6, floor 18.4 (N-2 minors; PostgreSQL numbers releases MAJOR.MINOR), checked 2026-09-26. The pin reader checkscoder_postgres_image_taginlab-host/ansible/group_vars/all.ymlagainst it. It accepts exactly MAJOR.MINOR and reports anything else, such as a moving18or a19beta4, as a finding rather than a pass.update-version-floorsreads endoflife.date'spostgresqlproduct rather than postgresql.org'sversions.json. That keeps one source format and keeps true the workflow's "endoflife.date's v1 API for each kind"; both said 18.6 that day. It adopts a new major at its.2, and skips any major whose latest release is not MAJOR.MINOR or is not yet released, so a beta or release candidate is never proposed.majorMinorFloorjoins the floor arithmetic. Nine tests cover the arithmetic, the reader (quoted and unquoted tags,18,19beta4,19rc1, a missing pin), the rule, the file's shape and the updater (minor move, major adoption at.2, five pre-release shapes, a backwards source). Setting the pin back to16.15, then to19beta4, turned the gate red with one line naming the file, the pin and the floor. A live--dry-runprintedNo floor moved. Coder v2.37.3 was checked the same day and is the newest release (ghcr.io/coder/coder:latestresolves to its digest), so it is unchanged. -
The lab agent and the Coder template pull the Python 3.14 / Debian 13 lab image.
vps-agent/lib/capabilities.jspinshcw-lab-runnerandlab-host/coder/templates/hcw-lab/main.tfpinshcw-labto the digests main's publish run 36221001115 pushed for cd9e1af4 (#716), both checked withdocker buildx imagetools inspect..vscode/mcp.jsonis ignored: it holds per-developer MCP servers and local paths. -
Node.js on the newest supported line, and version floors enforced in CI (#715, #714). Owner direction 2026-09-25: the latest current release.
functions/moves from Node.js 22 to 24, the newest line Azure Functions Flex Consumption supports (22 and 24 are GA; 26 is not offered): theenginesrange (^24.19.0),runtime_versionininfra/functionapp.tf(an in-place update: the attribute is not ForceNew in azurerm 5.6.0), thefunctions (azure)CI row, the functions half ofcoverage.yml,deploy-functions.yml, andupdate-learn-catalogue.yml, which importsfunctions/src. Everything else moves from 22 to 26: thefrontend/,frontend/scripts/,scripts/andlab-host/coder/enginesranges (>=26.8.0),frontend/.nvmrc, and every othernode-version:in.github/workflows/. The CI matrix now gives each row its own line (matrix.node-version, defaulting to 26) instead of one pin for all five.scripts/version-floors.jsonrecords the newest release and the floor for Python (3.14.7, floor 3.14.5; #714 asked for a separatepython-floor.json, folded in here), Node.js (26.10.0, floor 26.8.0; the functions ceiling 24.21.0, floor 24.19.0), Ubuntu (26.04 LTS), Debian (13) and Terraform (1.16.4, floor 1.16.2), each with its source and date.scripts/version-floors.test.mjsreads everynode-version:,python-version:andterraform_version:in the workflows,engines.nodein every trackedpackage.json, every.nvmrc,.node-versionand.python-version, the python, node, debian and ubuntuFROMlines of every tracked Dockerfile,runtime_version, the lab host'slabs_agent_node_version, Ubuntu target andPYTHON_VERSION,lab-image/versions.env'sTERRAFORM_VERSIONand everyrequired_version, and fails any below its floor naming the file, the line and the floor.update-version-floors.ymlre-reads endoflife.date and the Flex Consumption table on Microsoft Learn every Wednesday and opens one pull request when a floor moves; that pull request's CI then lists the pins left behind. Thescripts (operations)CI row now also runs on changes to the files the floors test reads, anddocs/standards/iac-repository-standard.mdnames the rule.vps-agent/package.jsonmoves to>=26.8.0here, because the Ubuntu 26.04 change (#717) moved the lab host to Node.js 26.10.0 and left the package range at>=22. On top of #717 and the Trixie lab image (#716), the floors test finds no pin below its floor. -
Lab host targets Ubuntu 26.04 LTS, with release-derived apt suites, resolute package pins and every lab host pin at its newest release (#715). The VPS was reinstalled with Ubuntu 26.04 LTS (owner decision 2026-09-26).
lab-host/bootstrap.shandsite.ymlaccept 26.04 and keep 24.04 as a stated fallback, refusing anything else;lab_host_ubuntu_releasesingroup_vars/all.ymlis the list. Docker's version strings name the release, so the pins are nowdocker_release_pins, one entry per codename: 29.8.1, containerd 2.3.6, buildx 0.37.1 and compose 5.5.1 for bothresoluteandnoble. Thearcrole derivespackages.microsoft.com/ubuntu/<version>/prodfrom the running release, and because Microsoft signs the 26.04 repository withmicrosoft-2025.asc(AA86F75E...F748182B) rather thanmicrosoft.asc, the key URL and its SHA256 are per-release maps, each checked withgpgvagainst its release'sInRelease;azcmagentstays at 1.68.03532.1399, the newest in both repositories. Microsoft Learn lists Ubuntu 26.04 on x86-64 as supported for Arc-enabled servers and for the Azure Monitor Agent (read 2026-09-26), so there is no support gap. Ansible's control side now runs on CPython 3.14.7 installed by uv 0.12.19 (release asset SHA256-verified and attested, nocurl | sh), because 26.04's/usr/bin/python3is 3.14.4, below the owner's two-patch floor; modules still run on/usr/bin/python3, the one recorded exception, sincepython3-aptandpython3-dockerare built for it.ansible-corestays at 2.21.4, the newest GA release.labs_agentmoves to Node.js 26.10.0 from NodeSourcenode_26.x, and itsnpm cistamp now includes the Node pin so a runtime bump reinstalls dependencies. The Coder template'scode-servermoves from 4.106.3 to 4.139.1.infra-lab/README.mdstep 1 now says to paste the token-reading line before copying the token, because copying the line overwrites the token on the clipboard. Verified in privileged systemdubuntu:26.04andubuntu:24.04containers: twobootstrap.shruns each, the secondchanged=0; the fail-closed state (apex 503, agent stopped, Coder down);azcmagentinstalled and failing closed without the vault; andvps-agent's 81 tests passing on Node 26. -
CI runs Python 3.14, the newest stable release. Owner instruction 2026-09-26: always use the newest Python. The four
actions/setup-pythonpins (the ansible-lint and harness jobs inci.yml,complexity-delta.ymlanddocs-pages.yml) move from 3.12 to 3.14. The docs build was run locally on 3.14.7 before the change. -
scripts/lab/Connect-Lab.ps1: one-command SSH and VS Code setup for the lab host, and the corrected Hostinger read ininfra-lab. The script (PowerShell 7,-WhatIf) sets up any desktop to reach the host ashcw-lab: a per-machine ed25519 key at$HOME\.ssh\hcw-lab_ed25519, created only if missing; exactly oneHost hcw-labblock in$HOME\.ssh\config, inserted ahead of anyHost *or replaced in place, every other line kept, a timestamped backup written first; the Windowsssh-agentservice started when it is present and stopped, and the key loaded; then the public key and the step that authorizes it. The host name comes from theLAB_SSH_HOSTrepository variable whenghcan read it, otherwiselab.hybridcloudworks.com;-User rootcovers the window before the firstbootstrap.sh, and-Codeand-Connectopen VS Code Remote-SSH on/opt/hcw-srcor a shell. The printed line for adding a key from a machine that already connects writes root'sauthorized_keysas well as the login user's, because the hardening role rebuildshcwadmin's from root's on every run (exclusive: true) and a key only there would be gone after the next one. The private key is never read, printed or sent, and a second run changes nothing and says so.Connect-Lab.Tests.ps1covers the config rewrite in 29 Pester cases, run by a new step inrepository-policy.ymlthat also fails on zero tests.docs/runbooks/labs-host.mdopens with "Connect from a desktop": the Remote - SSH extension, the run with and without a checkout (saved to Downloads and run, never piped toiex), the optionalLAB_SSH_HOSTvariable, the host-key check, and the Web Console as the browser fallback.infra-lab/README.mdstep 1 printed blanks for every field but the template, becauseInvoke-RestMethodpasses the bare JSON array Hostinger returns down the pipeline as one object (reproduced on PowerShell 7.6 and 5.1); it now reads the token from the clipboard, unwraps the list, printsid,plan,data_center_id,template_id,template_nameandstate, prints status and body on failure, and blanks the clipboard.planfollows the provider's import at v0.1.23, which keeps the VM'splanfield (such asKVM 4) unless the billing subscription lookup returns anitem_id. Steps 6 and 7 now use thehcw-lab_ed25519key and thehcw-labalias. - Azure Arc onboarding for the lab host: onboarding identity, data
collection rule, audit policy, Ansible arc role (#663). Phase 3 of #656,
the engineering half.
infra/lab-hybrid.tfadds the data collection ruledcr-lab-hybrid-prod-cus(heartbeat plusauth/authprivsyslog at Info and above, into the Management workspace, in the workspace's own region), the onboarding principal's only grant (Azure Connected Machine Onboarding onrg-lab-hybrid-prod-cus, planned once the owner setsarc_onboarding_principal_id, because the run identity has no Entra role and a Terraform-minted secret would sit in state) and the audit-only Linux security baseline assignment withIncludeArcMachinestrue (planned oncelab_hybrid_policy_enabledis set, because the run identity cannot write a policy assignment until the owner grants it Resource Policy Contributor on the group). The Arc resource providers joinazure_resource_providers; Defender for Servers stays off. The newlab-host/ansible/roles/arcruns afterhardening, off by default (arc_enabled: false):azcmagent1.68.03532.1399 from Microsoft's repository, signing key pinned by SHA256 and held, thenazcmagent connectwith the credential in a root-only temporary--configfile deleted in the same run; skipped once Connected, fail-closed without the fourvault_arc_*values otherwise. The Azure Monitor Agent and the rule's association are the owner's twoazcommands after onboarding, because both target a machine that does not exist before it. Labs host Arc onboarding is the owner procedure, from the principal to Connected, the heartbeat, the compliance state and the disconnect; Required-Inputs §4.1 and §4.7 carry the new names, and the §4.1 variable count, which had drifted to 62 against 63, now reads 65. - NVIDIA API Catalog as a purpose-scoped AI provider for content
generation (#701). A fourth router provider,
nvidia, on the OpenAI-compatible endpointhttps://integrate.api.nvidia.com/v1withNVIDIA_API_KEYas a Bearer token; available by key presence, and an unseeded Key Vault reference counts as no key. Its purpose → model table (CONTENTFORGE_NVIDIA_*_MODELoverridable) defaults toz-ai/glm-5.3for drafts,deepseek-ai/deepseek-v4.1-flashfor analysis andz-ai/glm-5.3-flashfor short calls, read from build.nvidia.com on 2026-09-25. It is placed per feature (PROVIDER_PLACEMENT_DEFAULTSinai-config.js): first for owner-triggered content (Forge drafting and grading, inspector, critique, voice calibration, captions, Listen & Learn and podcast scripts), after the others for the Telegram assistant, and locked off for the anonymous public explain features, image alt text and source grounding — configuration can demote or disable it, never place it in a locked feature. A sliding-window pacing guard keeps each instance at 36 requests a minute (NVIDIA_REQUESTS_PER_MINUTE); a paced call is not sent and fails over at once, as do NVIDIA 429/5xx/404/400 responses after the existing retries. Usage is recorded at zero cost. The AI Engine page lists and orders the provider and sets its placement per feature under "Where AI is used".NVIDIA-API-KEYgains a Key Vault reference ininfra/functionapp.tf, a secret-catalogue entry and a Required-Inputs §4.6 row; the owner seeds it. - infra-lab: adopt the existing Hostinger VPS by import and add the lab
DNS records (#661). A new root module,
infra-lab/, for the HCP Terraform workspacehcw/hcw-lab(ADR 0032), pinned tohostinger/hostinger0.1.23 andcloudflare/cloudflare~> 5.24. The owner already has the server, and in this provider creating ahostinger_vpsis a purchase and destroying one cancels the subscription. Sohostinger_vps.labcomes in through animportblock whose id is a workspace variable, hasprevent_destroy, and ignores changes toplan,data_center_idandpassword(ForceNew, so a change would be cancel and re-purchase) and totemplate_id(a change reinstalls the disk). Postconditions turn a mistyped identity variable into a plan error that names the right value.dns.tfwriteslab.hybridcloudworks.com(A, the VPS's IPv4) and CNAMEs to it for*.lab,coder.laband*.coder.lab, all DNS-only.coder.labneeds its own record because*.coder.labmakes it an empty non-terminal, which Cloudflare's wildcard does not answer for. There are no_acme-challengedelegations while there is no lab zone (the ADR 0032 interim). The provider cannot run a post-install script on an existing server, so the first Ansible run isbootstrap.shover SSH. The owner's steps and the expected first plan,1 to import, 4 to add, 0 to change, 0 to destroy, are ininfra-lab/README.md. Offline contract tests with mocked providers (infra-lab/tests/adopt.tftest.hcl) and the fmt, validate, tflint, test and Trivy steps run in the existingiac-validate.ymlcontexts, and Dependabot watches the new lock file. - The Phase 2 lab image digests are pinned, and the Coder template no
longer rewrites /etc/passwd (#698).
IMAGES.hcwLabRunnerinvps-agent/lib/capabilities.jsand thehcw-labpin inlab-host/coder/templates/hcw-lab/main.tfnow point at the digests main published for 02dd9595 (#695), so the three runner-image capabilities find thehcw-terraform-validate,hcw-helm-templateandhcw-kubeconformscripts instead of failing withexecutable file not found. Thathcw-labimage gives uid 65534/bin/bashand/tmp/home, so the template's temporarylocal.passwdanduploadblock (#693) are gone. -
Deploy Functions smoke check polls until the temporary access rule has propagated (#700). The post-deploy smoke test waited a fixed 20 s after adding its
ci-smoke-<run id>access rule and made one request, so on run 36175776422 it got 403 "Web App - Unavailable" before the rule reached every front end, and a healthy deploy went red. It now polls/api/healthevery 10 s for up to 150 s, logs each attempt's status, passes on the first 200 and fails with the last body in the step summary if none arrives. The rule add, thealways()removal and the origin-lock check are unchanged. The SCM window'ssleep 20is left as it is: it is waited on by the deploy action, not by a request of this workflow's own. -
The Coder status token is an expected unresolved secret until Coder can issue it (#682).
CODER-STATUS-TOKENis created by Coder, so it cannot exist before Coder runs on the lab host (#661); the Terraform comment and Required-Inputs §4.7 had asked for it to be seeded before the apply. It is now listed inEXPECTED_UNRESOLVEDinscripts/check-unresolved-secrets.mjs, reported every run but not as a failure, and the labs page keeps reading "not yet provisioned".CODER-URLis seeded now with its known value. -
hcw-lab image Phase 2: transitive AVM vendoring, source rewrite, job labels, helm-template and kubeconform capabilities (#675). Phase 2 of #658. All three vendored AVM pattern modules now
terraform initunder--network none:lab-image/vendor-avm.shrunsterraform geton each at build time, vendors every registry child once at/opt/avm/<name>@<version>(sixteenname@versionpairs across fourteen modules), rewrites eachmoduleblock inside the vendored copies to the child's relative path with itsversioncommented out, pins every child by tree hash inversions.env(AVM_CHILD_MODULES; the build fails on a mismatch, an unpinned child or an unused pin) and proves with a secondterraform getthat nothing reaches the registry. The provider mirror is now unpacked, soinitsymlinks providers instead of extracting 225 MB of azurerm onto the job's 64 MB tmpfs, andTMPDIR=/tmp/run, because a provider opens its socket inTMPDIRand every one died silently on the read-only root; both were measured under the agent's exact sandbox flags, where the Phase 1 image could not validate any azurerm configuration at all. Theterraform-validatecapability now runslab-image/bin/hcw-terraform-validate, which copies the payload to the tmpfs and rewritessource = "Azure/<module>/azurerm"blocks whose version constraint a vendored copy satisfies (Terraform's own operators, highest match wins) to that copy, leaving everything else untouched soinitfails loudly; the learner's files are never changed (ADR 0032, decision 5). Jobs may carrypayloadEncoding: "tar"(base64 of a tar, gzip accepted) for multi-file inputs: the agent unpacks it with its own parser, which accepts regular files and directories only and refuses absolute paths,.., symlinks, hard links and every extension header, under the unchanged 64 KB cap; the server validates the encoding per type and the claim response carries it. Every job container carries--label hcw.lab-job=<jobId>, asserted indocker-runner.test.js, and a job with no plain identifier does not start. Two capabilities join in all three places (capabilities.js,LAB_JOB_TYPES,FALLBACK_JOB_TYPES, now held together byscripts/lab-job-types.test.mjs):helm-template(helm templateon a tar of one chart) andkubeconform(-strictagainst one pinned release ofyannh/kubernetes-json-schemabundled at/opt/kubeconform/schemas, no API server). The tmpfs is mounteduid=65534,gid=65534,mode=0700, because Docker mounts it root-owned and the bare Phase 1 flag gavePermission deniedon the first write.lab-image/sandbox-check.mjsruns the five jobs through the agent's owndocker-runner.jsunder the full sandbox against the built image, in the publish workflow after the smoke tests. Thefulltarget gives uid 65534/bin/bashand/tmp/homein/etc/passwd, because Coder runs the startup script, code-server and the terminal through the passwd shell (#693). The pull and run commands for the toolchain, PowerShell and bash, are inlab-image/README.mdanddocs/architecture/labs-host.mdfor/education/labs(#681) to link.IMAGES.hcwLabRunnerpins the Phase 1 publish; the digest that carries this phase's image is bumped in a follow-up oncemainrepublishes. - Docker agentic sandbox recipe and the "Run an agent against your landing
zone" section on
/education/labs(#676). Phase 3 of #658.lab-image/sandbox-template/Dockerfileextends Docker's Claude Code sandbox template (docker/sandbox-templates:claude-code, pinned by image index digest) with terraform and the Azure CLI at the versionslab-image/versions.envpins — the build context islab-image/so the file is read from its one home, the Azure CLI package string's~bookwormsuffix is swapped for the base image's Ubuntu codename with the version number unchanged, and every download is checked against the pinned SHA256 — and installsAGENTS.mdas the agent user's~/.claude/CLAUDE.md: the folder is a landing zone generated for learning, runterraform init -backend=false,fmt -checkandvalidate, explain the files, neverplan,applyor reach a tenant.README.mdbeside it carries every command, PowerShell then bash: build,docker image saveandsbx template load, thesbx policy allow network --sandbox hcw-lzline the deny-by- default sandbox needs for the Terraform registry and GitHub, andsbx run --name hcw-lz --template hcw-lz-sandbox:v1 claude .. On the page, the#676slot is filled bySandboxSection: three numbered steps (build in the Landing Zone Builder and download the zip, unzip it, create the sandbox from that folder), thesbx runline under each shell through aCommandLinecomponent shared with the lab cards, the first prompt as one quoted sentence, the sentence that local sandboxes are free and cloud ones bill the learner's own Docker subscription and expire after an hour, and a link to the recipe directory. Everysbxfact was read from the Docker documentation on 2026-09-25 (docker sandboxis removed from Docker 29; the CLI issbx, custom templates are--template <image>onsbx create/sbx run, cloud sandboxes need a Docker Agentic Platform subscription, cannot mount a host workspace and expire after one hour by default) and the page cites the URLs in its source. The image built locally (3.38 GB) andterraform init -backend=false,fmt -checkandvalidatepassed inside it against a full builder emission; thesbxcommands themselves are the documentation's shapes, not exercised, since the CLI needs a hypervisor the build machine did not have enabled.validate-repository-structure.ps1allowlists the recipe'sREADME.mdandAGENTS.md. - Newsletter section "Lab this week" with a daily labs rollup timer
(#665). Phase 5 of #656. A new flag-gated timer
labsWeeklyRollup(functions/src/functions/labs-jobs.js,FEATURE_FLAG_LABS_WEEKLY_ROLLUP, daily 23:55 UTC, catalogued ininfra/functionapp.tfand allowed by theenabled_timersvalidation) writes onelabs:day:<YYYY-MM-DD>document per UTC day totool_service_cachewith a sixty-day TTL:{ arcConnected: boolean|null, jobsByType: { [type]: { succeeded, failed, timeout } }, coderRunningMax: number|null }, computed byfunctions/src/lib/labs/rollup.jsfrom three point reads (thelabs:estateandlabs:coder-statusminute-cache documents and any earlier document for the same day) and ONE grouped count oflab_jobsbounded on the indexedcreatedAtand the three terminal statuses — no per-job read, no full scan, and never a call to Azure or Coder. Null means not observed, not down:arcConnectedis a boolean only when an estate document stamped that day described a configured estate. A rerun for the same day merges — connected once stays connected, the Coder peak keeps the larger sample, job counts are recomputed. The newsletter sectionlab-this-week(functions/src/lib/newsletter/sections.js, registered last and on by default) point-reads the seven day documents before the build day and renders Arc uptime as days connected out of days observed, lab jobs by type with success counts, and the peak Coder workspace count, every item linking tohttps://hybridcloudworks.com/education/labs; it renders nothing, and the issue omits it, when no day document exists or none observed the estate or a job. Tests beside each module cover the rollup computation from fixture cache documents and grouped rows, the TTL, the timer's flag gate and disabled skip, the section with data, and the section omitted from a built issue without it;route-inventory.test.jsandtimer-schedules-utc.test.jsnow count twenty-two timers. -
Landing Zone Builder, Phase 4: "Explain this component" and the
landing-zonearticle embed (#670). Phase 4 of #657, frontend half, on the #669 backend.frontend/src/pages/tools/landingZone/LzExplainButton.jsxsits in the teaches panel beside the focused component and, on a click and never otherwise, sends{ kind: "landing-zone", componentId, selected, options, teaches }— the five keys the server's validator allows and no other, built from the normalised build and the catalogue's own text — toPOST public/cloud-tools/explainthroughrequestLandingZoneExplanationinlib/publicApi.js(the pricing call and it share onepostExplanation; the body'skindtells them apart). The answer is shown under a "Generated by AI" label with the model, the time and "cached" in words, is keyed to the exact body it explained so it goes away when the focus or the build changes, and reads the quota (429) and the paused or off states (503) as sentences with no retry, the way the pricing button does — the button, the panels and the click-only rule now live once inpages/tools/explain/ExplainControl.jsx, andscenario/ExplainButton.jsxis what the pricing page sends and the words it uses, its tests unchanged. Alanding-zonecode fence whose body is the build's share query string (lz=mg,policy,mgmt,hub,fw&corp=2) renders a read-only card in an article:components/content/LandingZoneEmbed.jsxis the lazy shell (the frame and placeholder shared with the pricing embed through the newEmbedShell.jsx) andLandingZoneCard.jsxthe chunk, decoding withdecodeLz, drawing the diagram through the page's own SVG (moved intolandingZone/LzSvg.jsx, with a non-interactive mode that leaves the boxes as plain groups), listing the selected components with a line each, and linking to the same build in the tool.markdownCodeComponentsincomponents/shared/CodeBlock.jsxnow dispatches through anEMBED_COMPONENTSmap,{ language: Component }, so the next tool's fence is one line; thepricing-scenarioentry and its tests are unchanged. The fence is documented besidepricing-scenarioindocs/content/blog-template.md. Tests: the button's body against a copy of the server's allowlist for every component, each response state, the answer dropped on a focus change; the embed's decoding, card, link, junk tolerance, pre-render and hydration; both fences dispatched by the map. -
Public labs reads: the Hybrid Lab estate and Coder status, with no browser call to either (#664, #680). Backend halves of Phase 4 of #656 and Phase 2 of #659.
GET /api/public/labs/estatereads the Arc-enabled lab host through Azure Resource Graph with the Function App's own managed identity, under a new Reader grant scoped torg-lab-hybrid-prod-cusalone (infra/lab-hybrid.tfcreates the group and the assignment; no new credential exists): the machine's status, last status change, agent version and OS name — never its name, which is the hostname — plus policy compliance counts for the group, whether avps-agentis heartbeating and how manylab_jobsare queued, and Coder capacity. It answers{ configured: false }when no machine exists and 503 when Azure cannot be read, never an invented row, and a side read that fails isnull, not zero.GET /api/public/labs/coder-statusis the server-side proxy the CSP requires:CODER_URLandCODER_STATUS_TOKENare Key Vault references toCODER-URLandCODER-STATUS-TOKEN(catalogued for the API-keys page under a new Hybrid Lab section), the token stays in the Function App, and the route returns template names with their active version, the running workspace count andCODER_MAX_WORKSPACES(default 5, the Community cap) afterGET /api/v2/templates,/api/v2/workspaces?q=status:runningand one/api/v2/templateversions/{id}per template with a 5 s timeout. Both routes are anonymous and cache one document a minute intool_service_cache, so visitors cannot drive the management plane or Coder; a failed Coder read is cached as unreachable so the card never shows stale numbers. Listed inPUBLIC_ROUTESand.azure/api-surface.json. Thehcw-azureapply that creates the group, the grant and the three app settings is the owner's run. -
Coder Phase 1: Docker Compose on the lab host, the Caddy route, the hcw-lab workspace template with its hardening test, and the Ansible role (#679). Phase 1 of #659, on ADR 0032.
lab-host/coder/docker-compose.ymlruns exactly two services,coder(ghcr.io/coder/coder v2.37.3) andcoder-postgres(postgres 16.15), both by image-index digest held once ingroup_vars/all.ymland interpolated through a role-written.env; the Docker socket is mounted intocoderonly, which runs as the image's non-root user withgroup_addset to the host's docker GID, publishes 7080 on loopback only, disables password auth and Coder's default GitHub app, and reads its secrets from/etc/hcw/coder/coder.env(root, 0600) with the database password in a second file the database alone sees.templates/hcw-lab/main.tfadapts Coder's Docker starter template: alabdropdown validated against the three catalogue ids, thehcw-labimage by digest,user = "65534:65534", hard limits of one CPU and 2 GiB, all capabilities dropped,no-new-privileges, a per-workspace named volume as the only mount, a per-workspace bridge network created and removed with the container so it never joins the Compose network, and the pinnedcode-servermodule on a*.coder.labsubdomain.template.test.mjs(node --test) reads both files as text and fails when the socket appears outside thecoderservice, anything isprivileged, ahost_pathormountsblock appears,network_modeor the Compose network is referenced, or the user is not 65534; the newcoder (lab-host)CI job runs it withdocker compose configandterraform validate. ThecoderAnsible role installs the Compose project, fails closed without a non-empty organisation allowlist, the three Vault keys or on a host too small forcoder_max_workspaces, brings the project up or down, renders/etc/caddy/conf.d/10-coder.caddy(503 with a sentence when the service is stopped, so the kill switch is legible), and keeps seven nightlypg_dumps under/var/backups/coder. Found on the way: the Coder agent runs every script through the user's/etc/passwdshell, and Debian'snobodyhasnologin, so the template writes the image's passwd back with a shell for uid 65534 untillab-image/does; andcoder templates pushhas no--default-ttlin the current CLI reference, so the one-hour autostop iscoder templates edit hcw-lab --default-ttl 1h. -
The Azure Verified Module pins are checked against the registry every week, and a newer release opens a pull request (#671). Phase 5 of #657.
frontend/src/lib/landingZone/avmVersions.jssays its pins are "looked up, not remembered";.github/workflows/update-avm-versions.ymlis what looks, Tuesdays at 06:30 UTC and by hand, modelled onupdate-learn-catalogue.ymldown to the two-job split, the App token minted for a few API calls and revoked, theautomationenvironment, and the ready-for-review pull request.frontend/scripts/update-avm-versions.mjsimportsAVM_MODULES, asksregistry.terraform.io/v1/modules/Azure/<name>/azurermfor each module's latest release, refuses anything that is notMAJOR.MINOR.PATCHtaggedv<version>, and for a module that moved rewrites exactly oneavm('<name>', '<version>', …)pin, restampsAVM_VERIFIED_ONwith the UTC date, downloads the release tarball from GitHub (the proof the tag exists) and, wherelab-image/versions.envvendors the module (#658), rewrites its_VERSIONand_SHA256lines with the tarball's sum so the image and the builder cannot disagree. Exit 0 and no writes when nothing moved;--dry-runprints without writing. The summary the pull request carries tables every pin's old → new with its GitHub release notes and says whether the release'srequired_providersdrifted from the pinnedrequiredProviders— the edit the script does not make, flagged as HAND EDIT NEEDED. Because the versions are in thehcl.test.jssnapshots, the workflow refreshes them withnpx vitest run -u src/lib/landingZoneafter a bump and then runs the same suites without-uas the gate, so the pull request carries the Terraform diff a learner would download and a drifted input name fails in the run rather than on the PR. The branch stages exactly the pins, the versions file and the snapshot, asserted byscripts/avm-versions-workflow.test.mjs; the comparison, validation, text edits and summary are the pure half infrontend/scripts/avm-versions-edits.mjsand are unit-tested beside the script;index.test.jsnow assertsAVM_VERIFIED_ONis a calendar date no earlier than 2026-09-25 instead of naming the day. -
/education/labs: the browser labs page, with the lab catalogue, Open in Coder deep links, Run it locally, and the estate and Coder status cards (#681). Phase 3 of #659, carrying the frontend halves of #664 (the "Hybrid Lab right now" card) and #680 (the Coder status card) against the contracts those issues fix.frontend/src/data/labs/catalogue.jsis pure frozen data — one row per lab withid,title,summary,tools,template,params,articleSlugsandestimatedMinutes, and a test that every row carries every field and no two share anid— starting with the Landing Zone Builder download, aterraform validatewalkthrough and an Ansible syntax-check walkthrough. Each card links tohttps://coder.lab.hybridcloudworks.com/templates/hcw-lab/workspace?mode=auto¶m.lab=<id>throughsafeUrl, and carries the twodocker runlines from #658, PowerShell then bash, each labelled with its shell.LabsEstateCard.jsxreadsGET /api/public/labs/estatethrough the newfetchLabsEstate()and renders "The lab host is not provisioned yet." for{ configured: false }and, when configured, the Arc status word, the heartbeat age in words, agent version, OS, policy counts, job-runner queue and Coder capacity;CoderStatusCard.jsxreadsGET /api/public/labs/coder-statusthroughfetchCoderStatus()and says "not yet provisioned" or "unreachable" before it shows templates and capacity. Every state on the page is a word, never a colour alone. Two slot sections hold the layout for the agent section (#676) and the article list (#677). Wired in App.jsx,STANDALONE_ROUTES,staticRoutes.labs, a new Learn menu in the header (the first header link to/educationas well) and a section on/education, each enforced by a test. - Landing Zone Builder, Phase 2: the page at
/tools/landing-zone(#668). Phase 2 of #657, on the pure module #667 landed.frontend/src/pages/tools/ LandingZonePage.jsxis where a learner assembles an Azure landing zone component by component: a build panel (landingZone/LzControls.jsx) with a checkbox per component, grouped platform and application, a count of 0 to 5 for corp and online landing zones, and one knob per option (region, parent management group, hub and spoke address spaces, private DNS zones, firewall SKU); a teaches panel (LzTeaches.jsx) that follows whichever component was last ticked, clicked in the diagram or asked about, with its hand-written explanation, what it needs, the pinned module that deploys it and its knobs; an inline SVG of the management group tree (LzDiagram.jsx) drawn fromlayoutDiagram, every box a button that focuses its component, with zoom and pan from thereact-zoom-pan-pinchthe site already shipped; and the generated Terraform (LzFiles.jsx), one tab per emitted file rendered with the site'sCodeBlock, plus a Download zip button.
The build is the URL. useLzState.js is useScenarioState.js adapted
to share.js: every control writes ?lz= and the option keys and reads
itself back, so a bare URL is the full default build, a shared link
reproduces a build exactly, and the pre-rendered page and the first client
render agree; a test renders the page to a string twice and hydrates the
result without a mismatch. The dependency rule is spoken, not just enforced:
a component says "Ticking it also adds Connectivity hub" before it is
ticked and "Unticking it also removes Azure Firewall" after, and a spoke
range the state moved off the hub is explained in a sentence, kept on the
page even once the URL carries only the moved value. An invalid range is
refused in the validator's own words and never reaches the URL.
The zip holds exactly the files the tabs show. fflate is the one new
dependency, imported inside the click handler so it is a lazy chunk a
reader who never downloads never fetches and the pre-render never
evaluates; the test mocks it and asserts the entry names equal the tab
names and each entry's bytes equal the file's content. Wiring, each held by
an existing test or the new one: the lazyPage route in App.jsx, the
standalone list in scripts/prerender-entry.jsx, staticRoutes.landingZone,
a Tools menu entry in Header.jsx, and a first card on /terraform/tools
that opens the page. The Azure education page has no tools list, so it got
no cross-link. The three placeholder tool slots are untouched. Helmet sets
the title and the canonical; the default build renders the management
groups' teaches text and a dozen file tabs, well past the prerender's
420-character floor.
-
The explain route is generalised by kind, and the Landing Zone Builder gets its "Explain this component" backend (#669). Phase 3 of #657, backend half.
POST public/cloud-tools/explainreads an optionalkindfrom the body: omitted orpricingis the #613 contract byte for byte (same validator, same canonical hash, samepricingExplaintoggle, same stored document — the existing tests are unchanged and the existing cached texts keep serving);landing-zonedispatches tofunctions/src/lib/cloud-tools/explain/kinds/landingZone.js, whose validator accepts{ kind, componentId, selected (<= 12), options, teaches (<= 1200) }against frozen copies of the catalogue's eight component ids and eight option knobs, every string capped, unknown keys refused, and whose prompt asks for two paragraphs — why this component matters for exactly this selection and options, then what changes if it is deselected — grounded in the page's ownteachestext and the four Azure Verified Module sources embedded per component, with no tenant and no prices. The cache, the 8 KB cap, the 5 per hour per client and the 200 per day are the same code and the same counters for both kinds: one anonymous AI budget. The landing-zone canonical text starts withkind, so its cache ids cannot collide with pricing's.AI_FEATURES.landingZoneExplainis the kind's own off switch, with its call site in the kind module soai-call-sites.test.jscan see the literal. The kind shape (id,feature,validate,canonical,cacheFields,generate) is documented onkinds/pricing.js;.azure/api-surface.jsonspells out thelanding-zonebody for the frontend phase (#670). Tests: kind dispatch, the unchanged default, an explicitkind: "pricing"hashing the same as none, unknown kind 400, the landing-zone validator's refusals, quota and daily pause shared across kinds, the toggle off for one kind while the other serves, and the prompt naming the component and every selected id. -
The lab host is configured by Ansible, not by hand over SSH (#662). Phase 2 of #656.
lab-host/ansible/holdssite.ymland five roles that replace the manual steps the admin Labs page's Setup tab has printed since #577:hardening(a key-onlyhcwadminwith passwordless sudo whose keys are copied from root's, so the first run cannot lock the host; an sshd drop-in named00-so it sorts ahead of cloud-init'sPasswordAuthentication yes; ufw deny-in/allow-out with 22, 80 and 443; unattended-upgrades rebooting at 04:30; a fail2ban sshd jail on the systemd backend, because Ubuntu 24.04 has noauth.log),docker(Docker Engine5:29.8.1with buildx and compose from Docker's repository, all held, with 10 MB x 3json-filelogs andlive-restore; the install carriesallow_change_held_packagesso a pin bump is not refused by the hold it set last time),node_exporter(1.12.1, SHA256-verified, loopback only),caddyandlabs_agent. Every version, digest and checksum is ingroup_vars/all.yml— including the SHA256 of the Docker and NodeSource apt signing keys, because a pin on the versions with no pin on the key that vouches for them is decorative — the collections are pinned inrequirements.ymldown tocommunity.docker's one transitive dependency, and every role carriesmeta/argument_specs.ymland a README.
Caddy is host-native and built, not downloaded, because the download API
does not pin. The stock apt package and the upstream image both lack the
Cloudflare DNS module, and caddyserver.com/api/download — the usual way
to add one — always builds the latest release: a request naming a version
that does not exist returned the identical 48,173,218-byte binary, so it
can be neither pinned nor checksummed. So the role runs xcaddy build
v2.11.4 --with github.com/caddy-dns/cloudflare@v0.2.4 inside the official
caddy:2.11.4-builder image — pulled and run by its pinned digest, never
by tag, so Docker verifies the content and a bumped pin is a new
reference that gets pulled — refuses the result unless caddy
version names the pin and caddy list-modules lists
dns.providers.cloudflare, and writes a .provenance file beside the
binary naming the three inputs. The binary's filename carries all three —
Caddy tag, module tag, the first twelve hex of the builder's index digest
— so bumping any of them rebuilds rather than relabelling an old build.
The provenance in the repository is the caddy_* pins in
group_vars/all.yml. It runs as caddy.service
as the non-root caddy user reading /etc/caddy/env through
EnvironmentFile= (owner root, group caddy, mode 0640 — the group read
is what lets renewals work — holding the runtime Cloudflare token from an
Ansible Vault variable, distinct from Terraform's), serving the three
names ADR 0032 puts on the certificate: lab.hybridcloudworks.com,
*.lab.hybridcloudworks.com and *.coder.lab.hybridcloudworks.com,
DNS-01 answered through the CNAME delegation of the two _acme-challenge
names to the lab zone. The site block imports conf.d/*.caddy: the apex
placeholder is 00-apex.caddy, and Coder (#679) adds a file rather than
editing the template. Without the token the Caddyfile fails closed —
an HTTP-only apex answering 503 that imports nothing from conf.d — so a
first bootstrap is green, the second turns TLS on, and a missing secret can
never put an application route on plaintext.
vps-agent runs host-native as the systemd service the Setup tab
described, which ADR 0032 settled. The agent's whole job is docker
run, so a containerised agent needs the Docker socket mounted and a Docker
CLI baked into an image the repository does not have, for no isolation gain
— socket access is host root either way — and the Agents tab's own
diagnostics already say systemctl restart hcw-labs-agent and journalctl
-u hcw-labs-agent. The role checks the repository out at a pinned sha,
runs npm ci --omit=dev once per ref (a stamp file, so a bump reinstalls
and a re-run does not), writes /etc/hcw/labs-agent.env with the exact
names from vps-agent/.env.example, and generates the key on the host
as that file asks. /etc/hcw/labs-agent.pem is root:hcw-labs-agent
0640, no ACLs — root owns it, the service reads it through its group,
nobody else can — and .env.example, the Setup tab's step 4 and
docs/standards/variables-and-secrets.md now all say exactly that instead
of the 0600 a non-root service could never have read. Only the public
half, /etc/hcw/labs-agent.crt, leaves the host. The certificate lasts
730 days and nothing renews it by itself, so every run checks it with
openssl x509 -checkend and warns within 60 days of expiry, and the
README carries the two-step rotation: register the next public
certificate first, then swap the PEM. The unit sets
TMPDIR=/var/lib/hcw-labs-agent/tmp, because lib/docker-runner.js
stages each payload under os.tmpdir() and bind-mounts it into the job
container, and the PrivateTmp /tmp the unit otherwise gets is one the
Docker daemon cannot see. Reviewing that surfaced a defect the runner has
carried since #577: mkdtemp creates the per-job directory 0700 as the
agent user while the container runs as 65534:65534, so /workspace was
untraversable and every job would have failed before its command ran.
prepareJobDir now makes the directory 0755 and the payload 0644 — the
directory is per job, holds only the payload copy the API already sent,
is bind-mounted read-only and is removed after the job — and
docker-runner.test.js asserts the modes. Until the vault holds the identity the unit is
installed, stopped and disabled and the env file is removed — explicitly,
so removing a value from the vault is a revocation on the next run, not a
file a manual systemctl start could reuse — and the play says which
values are missing. bootstrap.sh refuses to run when exactly one of the
vault file and its password file exists, rather than treating a
half-present vault as no vault and turning TLS off. The hcw.lab-job
label ADR 0032 requires on job containers is the agent's own change
(#675); this role sets no labels.
lab-host/bootstrap.sh — what #661's post-install script will call —
installs ansible-core 2.21.4 with pipx, clones the repository at the
pinned sha into /opt/hcw-src, installs the collections and runs
site.yml against localhost, passing the vault at /etc/hcw/ansible/
when it exists. CI gains an ansible-lint (lab-host) job on the
production profile plus a syntax check, gated in-job on lab-host/** the
way every other ci.yml job is. scripts/validate-repository-structure.ps1
allows the directory and its READMEs, and scripts/no-wiki-pointers.test.mjs
now reads .j2 files, the first tracked extension its coverage check had
never seen.
One contract is declared ahead of its role. group_vars/all.yml carries
coder_enabled: false and coder_oauth2_github_allowed_orgs: [] for
Coder (#679), and site.yml asserts the list is non-empty whenever the
flag is true — because an empty CODER_OAUTH2_GITHUB_ALLOWED_ORGS is not
a lock, it lets any GitHub account sign in. The README names the real
kill switches: CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=false for new learners,
stopping the coder Compose service for everyone.
- Landing Zone Builder, Phase 1: the pure module, with no page yet (#667).
frontend/src/lib/landingZone/ is the logic behind the Landing Zone Builder
(#657), built the way pricingScenarios/ is: frozen catalogues, small pure
functions, the state as a value and as a URL, and nothing that reads a
clock, a viewport or a tenant. components.js is the catalogue — six
platform components (management-groups, policy, management,
connectivity-hub, firewall, identity) and two application ones
(corp, online, each counted 0..5) — every one with a one-sentence
summary, a hand-written teaches of three to five sentences (what it is,
why a landing zone has it, what breaks without it), its dependsOn, the
module that deploys it, and its knobs: hub CIDR, spoke range, firewall SKU,
private DNS zones, region, the two counts and the parent management group,
each with a validator and a default.
The module pins are looked up, not remembered. avmVersions.js holds
exactly four modules with verifiedOn: '2026-09-25', each read from the
registry's v1 API and the repository's latest GitHub release that day:
Azure/avm-ptn-alz/azurerm 0.21.0, avm-ptn-alz-management 0.9.0,
avm-ptn-alz-connectivity-hub-and-spoke-vnet 0.17.5 and
avm-res-network-virtualnetwork 0.22.2. The pattern module for
application landing zones,
avm-ptn-alz-application-landing-zone-identity-and-access, was on GitHub
that day as the unfilled AVM template with no tag, no release and no
registry listing, so the builder does not call it: a landing zone is a
subscription_placement entry in avm-ptn-alz plus a spoke from the virtual
network module instead, and a test asserts every emitted module source is
pinned with a version line. The provider constraints (alz ~> 0.21,
azapi ~> 2.12, azurerm ~> 4.35, modtm ~> 0.3, random ~> 3.6, time
~> 0.9, Terraform >= 1.12, < 2.0), each module's requiredProviders
list, and the platform/alz/2026.08.1 library ref come from the same
modules' terraform.tf files at their tags and the library's release
list; every emitted module block's providers map is generated from
that list, since a block with a providers argument inherits nothing for
the providers it leaves out.
avm-ptn-hubnetworking is archived; a test asserts the string never
reaches any emitted file.
The build is a URL. share.js encodes the state as
?lz=mg,policy,mgmt,hub,fw,id&hub.cidr=10.0.0.0/16&spoke.cidr=10.1.0.0/16&fw.sku=Premium&dns=0&loc=westeurope&corp=2&online=1&root=contoso:
lz= lists the selected platform components by short id, the application
components travel as their counts with 0 meaning not selected, root= is
the management group the alz root is created under (absent for the tenant
root group, wired to the module's parent_resource_id), and every default
is left out, so the default build — the whole landing zone with one corp
and one online — is a bare URL and an empty build is ?lz=&corp=0&online=0.
Decoding drops unknown tokens, defaults any option that fails its
validator, and normalises through state.js, which closes the selection
over dependsOn (a firewall brings the hub; identity, corp and online each
bring the tree and the hub they peer to), keeps the counts and the
selection in step, and checks the two ranges against each other: a spoke
range that overlaps the hub is moved to the first free fallback
(10.1.0.0/16, 10.2.0.0/16, then 172.16.0.0/16) and the state carries
a warnings entry ({ code: 'spoke-cidr-overlap', from, to }) for the
page to show. isLzParam says which keys are the module's, as
isScenarioParam does for the pricing page.
hcl/ emits the Terraform the validated pattern would, one module per
emitted file. emitFiles returns { path, content } files, only for
selected components: terraform.tf, providers.tf (default providers from
the workspace's ARM_* variables, an azurerm and an azapi alias per
subscription), alz.tf (architecture_name = "alz", one placement per
subscription, and when policy is selected all fourteen
policy_default_values the pinned library declares — the six AMA and
workspace ids from the management names, the three private-DNS values from
the hub, the location, two resource-group names and a validated
security_contact_email variable — with the one value the build never
creates a source for, the DDoS plan, and the DNS trio when zones are off,
handled by setting their assignments DoNotEnforce rather than leaving a
library placeholder; when policy is not selected the tree is still the
alz architecture, which carries its baseline, so every one of the 123
assignments the twelve archetypes make at that ref is emitted with
enforcement_mode = "DoNotEnforce" and the defaults the build can
supply are still passed, with a comment saying the baseline is present and
inert because Policy was not chosen), management.tf, connectivity.tf
(the firewall and
its policy as blocks of the hub object only when selected, the DNS resources
on the option), identity.tf and application.tf (one spoke per landing
zone: a resource group, a /24 carved from the spoke range with cidrsubnet,
a two-way peering to the hub through the connectivity module's
virtual_network_resource_ids, and for corp and identity a route table
whose default route is the firewall's private IP when the firewall is
selected; online spokes egress directly), variables.tf with GUID
validation on every subscription id, the range variables carrying the same
rules as the JS validators — a shape check (dotted quad, prefix within
HUB_PREFIX_RANGE /8–/24 or SPOKE_PREFIX_RANGE /8–/20, both generated
from the constants isCidr and isSpokeCidr use, and cidrhost able to
parse it) and, on the spoke range, the hub-versus-spoke overlap rule as a
second validation that reads var.hub_address_space (Terraform 1.9 lets
a validation read another variable) and is guarded so it never evaluates
on a string that does not parse; proven offline with terraform plan on
the emitted file: the apart pair plans, a /25 spoke, a /25 hub, a
garbage string and an octet of 256 each fail the shape check, and
identical, hub-inside-spoke and spoke-inside-hub each fail the overlap
message — and a non-empty, /-rejecting check on
var.parent_management_group_id, because avm-ptn-alz's
parent_resource_id is the parent group's name and the module itself
refuses a resource id, subscriptions.tf (every placed subscription id in
one list behind an output precondition that refuses duplicates, since a
subscription can sit under one management group only; a duplicated id
fails the offline plan on that message, a distinct set passes),
terraform.tfvars.example with distinct placeholder ids, and a
README.md carrying the pattern's prerequisites — HCP Terraform, Owner at
the tenant root, a service principal — the spoke pattern in two sentences,
and the line that the files were generated for learning and never applied
by HybridCloudWorks. format.js aligns = the way terraform fmt does,
across runs of single-line attributes and not across a multi-line value or
a comment; six builds (default, Basic firewall, hub without DNS, tree only,
five-and-five Premium, identity only) pass terraform fmt -check
-recursive under Terraform 1.15.8. cidr.js is the carve — corp from the
low half, online from the high half, identity at the top of the low half —
and diagram.js lays the same state out as a tidy tree of management
groups, hub and spokes with their /24s and peering edges, in fixed units so
pre-render and hydration agree.
Validators refuse what Number would quietly accept: a CIDR must be
canonical (010.0.0.0/8, 10.0.0.0/08, /+8, /8 and /8.0 are all
rejected, not read as 10.0.0.0/8) and a landing-zone count is a number or a
string of digits only, so a blank field or corp=%20 falls back to the
default instead of becoming zero and deselecting.
Three suites, 209 tests, each emitted file and each module block its own
row: every component has every field and a teaches of the right length, the dependency closure, the carve, canonical-form and
count validators, the overlap fallback and its warning (through
normalizeState, setOption and a decoded URL), encode/decode round trips
and default omission, decode tolerance, fmt shape on every emitted file,
every module source pinned with a version, the archived and the
unpublished module absent, all fourteen policy defaults supplied or their
assignment not enforced, all 123 baseline assignments DoNotEnforce when
Policy is off, both variables.tf cross-checks emitted, committed
snapshots of the default and the tree-only builds (the repository's first
__snapshots__), and diagram determinism with no overlapping nodes. No
page, no route, no dependency; frontend/ vitest goes from 2,272 to 2,481.
- ADR 0032 records the learner labs platform, and the two documents that misdescribed the labs are corrected (#660). Phase 0 of #656: the decisions every sub-issue in the four lab epics (#656, #657, #658, #659) builds on are written down once, as a Proposed record, before any of them is implemented.
docs/decisions/0032-learner-labs-platform.md: the Hostinger VPS is Terraform-managed throughhostinger/hostingerin its ownhcw-labworkspace so lab state never meetshcw-azure; Ubuntu 24.04 with Docker Engine as the only runtime and no Kubernetes (owner decision 2026-09-24); Azure Arc as the hybrid control plane with heartbeat and auth syslog only and Defender for Servers off; Coder as the learner identity boundary the site links to and never embeds; digest-pinned images fromlab-image/as the one toolchain; and anonymous public submission held Gated with its bounds stated. Listed in the register and the site nav.docs/architecture/labs-host.mdis the estate record for the host — provider, plan, OS, what runs, exposure, backup posture and the identities it holds — every row marked planned, with no addresses and no secrets.docs/architecture/architecture.md§5.3 said the browser submits a lab request. It never has:submitPublicLabJobwas deliberately not ported (functions/src/lib/labs.js). The section now says submission is admin-only through/admin/labsandenqueueLabJob, and points at ADR 0032 for the gated public path.docs/standards/required-inputs.md§4.7 gains the inputs the ADR names —hostinger_api_tokenandcloudflare_api_tokeninhcw-lab, the Arc onboarding credential in Ansible Vault,CODER-URLandCODER-STATUS-TOKENin Key Vault (read by the underscored app settings) — all MISSING.-
.github/CONTRIBUTING.mdstill said new work lands inTODO.md. It lands as an issue on org project 1 with a Priority set (owner decision 2026-09-05, #362);TODO.mdkeeps the accepted risks and an index. -
The
hcw-labimage: one Dockerfile withrunnerandfulltargets, a Terraform provider filesystem mirror, three vendored AVM pattern modules, and a workflow that publishes both to GHCR with provenance attestations (#674). Phase 1 of #658.lab-image/Dockerfilebuilds from a digest-pinneddebian:bookworm-slimand installs terraform 1.16.4, kubeconform 0.8.0, helm 4.3.0 and ansible-core 2.19.13 (the last line that runs on bookworm's Python 3.11), every download checked against the SHA256 inlab-image/versions.env— the one file a version bump edits. Therunnertarget carries/opt/terraform/mirror, built withterraform providers mirrorfor azurerm 5.7.0 and 4.81.0, azapi 2.12.0, alz 0.22.0, random 3.9.1, modtm 0.4.0 and time 0.14.2, each zip re-verified against the registry's published sum after mirroring, and aTF_CLI_CONFIG_FILEthat installs from the mirror only:terraform initunder--network nonesucceeds for anything the mirror holds and fails loudly for anything it does not, instead of hanging on a registry it cannot reach. avm-ptn-alz 0.21.0, avm-ptn-alz-management 0.9.0 and avm-ptn-alz-connectivity-hub-and-spoke-vnet 0.17.5 are vendored at/opt/avm/<name>@<version>from their release tarballs; the fourth module the issue names has no release yet (its repository is still the AVM template) and is recorded as such inversions.env. The image runs as uid 65534 with/workspaceread-only, everything it writes under/tmp. Thefulltarget adds Azure CLI 2.90.0 from Microsoft's apt repository (key verified by SHA256, package version pinned), kubectl 1.37.1, git, curl, jq and code-server's three prerequisites.
What the offline init proves and what it does not, measured rather than
assumed: lab-image/smoke.sh first asserts there is no network, the mount
is read-only and the uid is 65534 — an init that passed with network would
prove nothing about the mirror — then compares every tool's reported
version with versions.env, and runs terraform init -backend=false on a
root using azurerm and random and on the vendored management module, both
with no network. The management module is the only one of the three whose
init needs nothing outside the mirror; avm-ptn-alz calls one registry
module (Azure/avm-utl-interfaces/azure) and the connectivity module
calls thirteen distinct ones across twenty-one module blocks, and
Terraform has no module mirror, so those two need a registry for their
child modules. lab-image/README.md says so, with the
measured sizes.
.github/workflows/publish-lab-image.yml builds both targets and runs the
smoke test in each on every pull request touching lab-image/** (loaded
locally, never pushed), and on a push to main pushes
ghcr.io/hybridcloudworks/hcw-lab-runner and
ghcr.io/hybridcloudworks/hcw-lab tagged with the commit sha and latest,
attaches an actions/attest-build-provenance attestation to each, and
writes the digests to the job summary for #675 to pin from. Every action
is pinned by commit sha; the permissions live on the job. The repository
structure policy admits the lab-image directory and its README.
Changed¶
- Podbean MCP is retired from the AI Engine; the site reaches Podbean
through its API. Seeded on 2026-10-13 (#1114) and retired the same day,
on two measurements against
mcp.podbean.com: - its sign-in server allowlists redirect addresses to Claude, ChatGPT,
Cursor and loopback apps. Registration with the site's callback was
refused, and a client-metadata document listing it (which Podbean does
read) still answered
Unregistered redirect_uri, while a loopback address in the same document was accepted; - its MCP server answers
401to a Bearer token minted by a Podbean developer app, so no token the site can hold is accepted.
No web app can connect to it, this site included. podbean-mcp joins
DEPRECATED_MCP_SERVERS and is deleted on the next AI Engine load; its
credentials-register row goes with it. The developer app's credentials do
work against api.podbean.com, which is how publishing (#1117) already
reaches Podbean, and where any further Podbean capability for the AI
Engine is built (#1115, re-scoped).
- Approved podcast episodes publish to Podbean; the RSS.com integration is
gone (ADR 0029 §1c). Hybrid Cloud Insights moved to Podbean on
2026-10-13, and its feed is now
https://podcast.hybridcloudworks.com/feed.xml. RSS.com's API path never ran: RSS.com Max was never bought (#432), so every approval was recorded as not configured. - The client:
functions/src/lib/podcast/podbean.jsreplacesrsscom.js. It was written against Podbean's own API documentation data. It exchanges a developer app's client ID and secret for an access token (client credentials) and caches the token for its life less a minute. It authorises the upload, PUTs the MP3 to the presigned URL with no token, and creates the episode withPOST /v1/episodesaspublishandpublic. The episode notes are escaped HTML with a link to the article. A re-run updates the episode the document names and never creates a second. - Retries: the create is never repeated. The one repeat is a call
refused with
invalid_token, once, with a fresh token. - A lost create is found, not duplicated (#1118). Podbean takes no
idempotency key, and CodeRabbit held this to be fixed before merge.
- When: before any create on a document with an earlier attempt and
no episode id, the publish lists the show's newest episodes
(
GET /v1/episodes). - Match: one with the same title, published between ten minutes before the document's first attempt and twenty minutes after its last recorded one, is adopted and updated, with the audio re-sent. The upper bound keeps a late retry from adopting an unrelated episode published later under the same title.
- Fails closed: a listing that fails or cannot be read creates nothing, and neither does an unreadable attempt time (non-retryable).
- Recorded:
firstAttemptAtandadoptedAtare kept on the record.
- When: before any create on a document with an earlier attempt and
no episode id, the publish lists the show's newest episodes
(
- Credentials stay on the server: no error, log line or recorded failure carries the token, the secret or a URL holding either.
- Same job, same retry route, new record:
publish-podcast-transcriptandPOST cms/podcast/transcripts/{id}/publishare unchanged. The outcome is stored underhost.podbean, with the episode id, page, media URL and file key.host.rsscomis neither read nor migrated, because it only ever heldskipped: 'not_configured'. The job's timeout is now 10 minutes, above the client's deadlines including the one re-auth. - Integrations: a Podbean card replaces RSS.com's, with its badge,
directory row and health probe. Its test makes the connection probe's
first token exchange and then reads the show
(
GET https://api.podbean.com/v1/podcast). The exchange is a new, optionalauthenticatestep on a probe, held to one POST and redacted like the GET. The card answersConnected — <show title>.and records no key verdict. - Recording Hub and admin copy name Podbean, and a published episode links to its Podbean page.
- Public copy: both vendor guards now refuse "podbean" in public copy and public API messages.
- Keys: the catalogue, the credential register, the expected-unresolved
list and the docs swap
RSSCOM-API-KEYandRSSCOM-PODCAST-IDforPODBEAN-CLIENT-IDandPODBEAN-CLIENT-SECRET. The client ID is an identifier; the secret is rotated by hand in Podbean's developer portal. - The Terraform plan shows four app-setting changes on
azurerm_function_app_flex_consumption.hcw, all declared inscripts/assert-expected-plan.mjs.RSSCOM_API_KEYandRSSCOM_PODCAST_IDare removed.PODBEAN_CLIENT_IDandPODBEAN_CLIENT_SECRETare added as Key Vault references toPODBEAN-CLIENT-IDandPODBEAN-CLIENT-SECRET. -
Owner steps: create a developer app at
https://developers.podbean.com/, then paste its client ID and client secret onhttps://hybridcloudworks.com/admin/integrations?tab=keys. Success is the Podbean card's test answeringConnected — Hybrid Cloud Insights.. Until both are seeded, approvals stay on the manual upload path, exactly as before, and the two settings are on the expected-unresolved list, so the secrets monitor stays green. -
The README is written for someone new to the project, and the engineering detail moved to the docs site (owner, 2026-10-11).
-
README: the README now leads with what a visitor can do on the site:
- learn a platform;
- plan with the pricing comparison, decision matrix and Landing Zone Builder;
- assess an estate with the migration assessment, now live;
- practise in the labs.
A start-here table follows, then one short paragraph on how the site is built, and a brief contributor path with a five-line local start. - New docs page: the architecture diagram, the layer table, the repository map, the full "run it locally" guide and the CI/CD description moved, intact, to How HybridCloudWorks is built. It is on the docs site under Architecture and updated for the tool add-ons and the alert fire tests. - Badges: they are grouped as status (CI, CodeQL, docs build, reviews, maintainability, coverage) and links (both sites, the work board, open issues, licence, last commit). The work-board and open-issues badges are new, and every badge was checked to render a real value. - Docs home page: it now opens with a "New here?" section. - Fire record:
alert-kv-config-writeis recorded, fired at 04:52Z byInvoke-AlertFireTest.ps1 -Test kv-config-write. -
The HCW Work Board is public, so CONTRIBUTING links it again (owner, 2026-10-10). Open work stays GitHub issues; CONTRIBUTING, TODO.md and the code-review skill now name the board as public and link it (https://github.com/users/saulpatinojr/projects/6), where #1045 had pointed visitors at Issues alone while it was private.
-
The former organisation's board links and the docs CNAME now follow the repository to its owner's account (public readiness).
infra/frontend.tfpointsdocs.hybridcloudworks.comatsaulpatinojr.github.ioinstead ofhybridcloudworks.github.io(both resolve to GitHub's Pages addresses, so visitors see no change, and GitHub's custom-domain check expects the owner's own host);scripts/assert-expected-plan.mjsdeclares the update for the plan check. CONTRIBUTING sends open work to GitHub issues, since the Work Board is a private user project an anonymous visitor cannot open; TODO.md and the code-review skill link that board (user project 6), and TODO.md no longer claims the board adds new issues itself: its built-in workflows add sub-issues only, so whoever opens an issue adds it. The scheduled catalogue warning's comment says the same. -
README: the repository's front door, for a visitor and a contributor (#1033). The README opened as "HybridCloudWorks Website" and read as a migration-era record. It now says what the site is and who it is for, shows the running architecture as a draw.io diagram with the official Azure icons (
docs/assets/architecture/hcw-architecture.drawio, exported to SVG so GitHub and the docs site show it alike), with the same picture in words beneath it (built frominfra/,docs/runbooks/edge-dns-verification.mdanddocs/architecture/labs-host.md, so Cloudflare proxies the API hostname only), maps every top-level directory with a link to its README or its docs page (.vscode/and.qlty/included), gives each package's install, test, CI and start commands from itspackage.json, names the runtime lines and leaves the exact floors toscripts/version-floors.json, says which checks CI runs for which package and that the docs site deploys only on a merge that changes its sources, and links CONTRIBUTING, SECURITY, SUPPORT, the code of conduct, the docs site and the licence. Open work is pointed at GitHub issues andTODO.mdat the accepted risks. The badge rows are unchanged. The dated history is gone from the page; the migration record was already underdocs/history/and is linked once. The owner chose draw.io over Mermaid, whose boxes are generic and whose layout tangles; the docs hook (scripts/docs/hooks.py) now rewrites an image underdocs/to the site's own copy, so the README's diagram shows on the docs site too. Community health files (owner decisions, #1033):.github/CODE_OF_CONDUCT.mdis the Contributor Covenant 2.1, verbatim from contributor-covenant.org with the enforcement contact filled in as the owner gave it (the two hybridcloudworks.com addresses), and.github/SUPPORT.mdsends bugs and requests to the issue templates, documentation questions to the docs site and vulnerabilities to SECURITY's private reporting. CONTRIBUTING links both, and the repository-structure policy allows both beside CONTRIBUTING and SECURITY. Two fixes found on the way:scripts/docs/hooks.pystripped a leading.from every repository path (lstrip("./")takes a set of characters), so the README's link to.github/CONTRIBUTING.mdon the docs site pointed atgithub/CONTRIBUTING.mdand returned 404; it now strips only a leading./or/. And.github/SECURITY.mdsaid accepted risks were recorded in the README, which has not held them; they are inTODO.md. -
Terraform identity, SEC-1 step two: subscription Contributor comes off once the narrow grants are read back (estate review 2026-10-06, finding SEC-1; #971).
scripts/bootstrap-terraform-oidc.ps1gains-RemoveSubscriptionContributor, off by default. With it the script stops granting Contributor at subscription scope, reads every narrow grant back from Azure (Contributor on each of the nine groups, theHCW Terraform Subscription Scopeassignment and a definition holding exactly the JSON's actions, and RBAC Administrator whose condition names Owner and Contributor, on all three subscriptions), refuses with nothing removed if one is missing, and only then deletes the identity's three subscription-scope Contributor assignments, printingContributor at subscription scope removedfor each and reading each back.-WhatIfprints oneWould removeline per subscription. A plain run keeps step one's behaviour and grants subscription Contributor back, which is the rollback, so every re-run after step two carries the switch. The audit of whatinfra/does outside its nine groups found nothing the narrow grants miss on plan or apply: writes above a group are the custom role's (groups, budgets, provider registration), reads above a group are RBAC Administrator's built-in*/read, every cross-group reference has both ends in declared groups, and Contributor carries no data actions, so the custom role gains nothing. The one gap is destroy-time: azurerm's purge of a deleted Cognitive Services account is a subscription-scope delete the role deliberately lacks, so destroying the Foundry account is an owner step.scripts/terraform-identity-grants.test.mjsnow classifies everyazurerm_*/azapi_*typeinfra/uses by the grant that covers it and fails on an unclassified one, on a resource group other than a declaredazurerm_resource_group, on a literal Azure id above a declared group (the subscription budgets excepted), on a second delete in the custom role, and if the bootstrap's removal ever runs ahead of its refusal.Set-TerraformRbacCondition.ps1's recovery path already keys on the custom role as well as subscription Contributor, so it keeps working after step two; its comments now say so. ADR 0005 gains the dated amendment "2026-10-07, step two" with the audit table, the gap and the rollback; the deployment runbook, section 0, gains the owner's step-two procedure, the read-back one-liner, and how to read anAuthorizationFailedfrom the first apply. -
Lab host: the agent leaves the docker group behind its own socket proxy, the daemon remaps user namespaces, and Coder's workspaces move to a rootless daemon (estate review 2026-10-06, finding LAB-5; #951). The remainder #900 left open. The agent:
hcw-labs-agentis in no group but its own (the role takes it out ofdocker), and thedockerCLI it spawns reaches the host daemon throughhcw-labs-agent-docker-proxy, HAProxy 3.4.6 (newest LTS) by index digest, on a Unix socket only the agent's group may open, in a container with no network, a read-only root and no capabilities. Its allowlist, inroles/labs_agent/templates/docker-proxy.haproxy.cfg.j2, is the exact callsdocker run --rmanddocker rm -fmake for a job, recorded from Docker CLI 29.8.1 against a stand-in daemon (scripts/lab/capture-docker-cli-requests.mjs, fixture inscripts/fixtures/); no pull, exec, inspect or list. Because a path allowlist cannot see a privileged create, the proxy also reads the create body and refuses privileged mode, host namespaces, the remap's opt-out, added capabilities, devices, mounts, any bind but the job's own read-only directory, unmasked paths, other security options or networks, and non-digest images; it refuses any byte outside ASCII and any backslash, which is what keeps those regular expressions sound against Go's case-folding JSON decoder.scripts/lab-host-docker-proxy.test.mjsevaluates the file's own rules against the recorded calls and the escapes, and CI parses it withhaproxy -cat the pinned digest. User namespaces:daemon.jsonsetsuserns-remap: defaultwith the containerd image store off (that store does not support the remap), so a job container's root, and Coder's server's and PostgreSQL's, is an unprivileged uid. The two proxies and Portainer opt out to open a socket, as does the one-off Caddy build. The switching run stops every container, restarts the daemon before the later roles (a flushed handler), and copies Coder's PostgreSQL volume (owners shifted into the range) and Portainer's (as is) into the new data root once withhcw-docker-volume-carry, refusing a non-empty destination or a copy older than its source; its test runs in CI as root.-e docker_userns_remap=falseis the way back. Coder: the newcoder_sandboxrole runs a rootless Docker daemon (docker-ce-rootless-extrasat thedocker-cepin) as the unprivileged userhcw-coder-docker, with its own subordinate ids and the cpu, memory and pids controllers delegated;coder-docker-proxynow mounts that daemon's socket directory instead of the host's socket, so a privileged container a compromised server or template asks for is that user's, not root's. An authorisation plugin was rejected (ADR 0032, amendment of 2026-10-07).lab_imagesplans each daemon on its own (--scope jobs,--scope workspaces). A newprivilege_checksrole ends every run by failing unless the agent is out of the docker group,docker infoshowsname=userns, a job run as the agent through its proxy succeeds whiledocker psand a privileged run are refused, Coder's proxy carries the Compose file's policy with the sandbox socket as its one mount, and that daemon is rootless. Owner steps: re-runbootstrap.sh; running jobs and workspaces stop, and workspaces start again on the sandbox daemon with an empty home volume. The checks to paste afterwards, and what good looks like, are in the Labs host runbook, "Container-runtime privilege separation (LAB-5)". -
Lab host: Coder v2.38.0 and code-server 4.140.0, with code-server's release notice off (owner, 2026-10-07; #911). The Coder pin moves from v2.37.3 to v2.38.0 (released 2026-10-06; index digest read from
imagetoolsand the registry'sDocker-Content-Digest, which agreed; its breaking changes, Tasks removal, MCP private-network CIDRs and HA pubsub, touch nothing this host uses). The workspace template pins code-server 4.140.0 (Code 1.140.0) and passes--disable-update-check: the "a new release is out" notice's link opened a window the site's sandboxed pane could not navigate, which is the blank window of #911, and the release is the pin anyway. Owner steps: re-runbootstrap.sh(pulls the new Coder image and recreates the server), thenhcw-coder-template-pushso the template version carries the new code-server; existing workspaces take it on their next start. -
Plan-check declarations trimmed to the one still pending (2026-10-06). The
hcw-azureruns after the stale #908 plan was discarded applied the three lab rule predicates, theSEND_REMINDERSflag (false, thentrueonceenabled_timersnamed it), and confirmed the SEC-2 deletes, the ADR 0032 decision 6 keys and the #816 role removal are in state, so their entries inscripts/assert-expected-plan.mjsare deleted as its header asks.FEATURE_FLAG_PROBE_AI_PROVIDERSis stillfalsein state, so its arming declaration stays. -
Anthropic is a certification issuer, and the Top issuer stat wraps (owner request 2026-10-05).
lib/certIssuers.jsgainsanthropic(aliasesclaude,anthropic pbc; learn link anthropic.com/learn; its own colour), so the Certifications editor's issuer combobox offers it and a cert typed as "Claude" maps to it. The Catalog tab's Top issuer card shows two lines instead of an ellipsis for a long label. NewcertIssuers.test.jspins the registry's shape, the exact-or-alias matching, and what a cert gets from its issuer. - Update Cert Catalog sits in the Certifications Hub header (owner
request 2026-10-05). The publish button was on the Publishing tab, one
tab away from where edits happen; it is now under the Powered-by pill on
every tab, renamed Update Cert Catalog.
ServicePageHeadergains anactionsslot andPublishSnapshotButtonalabel(the Speaking Events tab keeps "Publish snapshot"). The Publishing tab keeps the snapshot summary and the changes-since diff, and re-reads after a header publish; the delete dialog, the editor and the moved-tab note name the new button.CertificationsPage.test.jsxpins the button in the header and out of the panel, and the tab's re-read after a header publish. -
About page: a composition root over per-section modules (#842).
frontend/src/pages/shared/AboutPage.jsxwas one component with 70 return statements and a complexity of 130 under a file-wideeslint-disable complexity, flagged by qlty on every PR that touched it. It now arranges six modules underpages/shared/about/: the hero, the speaking section, the certification registry (its four bodies as four small components), the issuer group, the card and the badge modal, with the data inuseCertifications.jsand every ordering rule incertificationSorting.jsas tables (certificationSorting.test.jspins the orders the page always produced). Theeslint-disableis gone; no function in the touched files has more than five returns or a complexity above 18. Behaviour unchanged: the prerendered/aboutis byte-identical before and after (17,296 bytes, checked with a temporary render test), the card's markup is byte-identical across every branch for a fixture set, and the public-copy, accessibility and existing About tests pass unchanged (4,069 frontend tests in all). -
AI router: the factory's closures are module-level functions over a context (#843).
createAiRouterinfunctions/src/lib/ai/router.jswas one 780-line factory with 24 return statements and a complexity of 137, flagged by qlty on every PR that touched the file. It now builds a context (env,fetch, the clock, log, store, the configuration loader, the NVIDIA pacing guard and the key-verdict reporter) and binds the public surface to it; chain resolution,callWithFailover, the per-provider callers and the generate calls takectxas their first argument. Two of the hoisted functions were over the threshold on their own, so the OpenAI-compatible request body, the grounded call's usage row and its completion check are their own functions. In the touched code no function has more than three returns and none is above complexity 18 by ESLint's rule. Key handling, theCONTENTFORGE_AI_PROVIDERpin, per-feature placement and routing, failover order, retry and time budgets are unchanged:router.test.js,sync-budgets.test.js,ai-call-sites.test.js,nvidia-provider.test.jsandprobe-ai-providers.test.jspass without edits (4,604 backend tests in all). -
NVIDIA's setup note and the seeding reference warn that a fresh key is refused for a while (#701). On 2026-10-04 a key seeded at 08:02 UTC got
403 Authorization failedfrom the app at 1, 12 and 13 minutes, the same reply a made-up key gets, then passed from a workstation at about 23 minutes and from the app at 37; a key seconds old passed at once from Azure Cloud Shell. Four reseeds and a rotation were spent on a key that was never wrong. The card's setup note anddocs/standards/required-inputs.mdnow say to Test again later before reseeding and to delete the previous key only after the new one passes from the app.notesjoins the seed-owned provider fields (frontend/src/lib/aiEngine/seed.js), so the note reaches the stored card rather than only a card seeded after it was written. -
September's measured cost replaces the estimate (#822).
docs/architecture/cost-analysis.mdnow states September 2026, the estate's first full month incentralus, from Cost Management (actual cost, by resource group and service, read 2026-10-03). The application subscription cost USD 34.32 and Platform Management USD 2.49, so USD 36.81 combined. That replaces the five-day reading of 2026-08-24 and the "USD 15–20" estimate. The page records the query and that the figures can still move slightly while September's billing finalises. - The total landed near the combined estimate, but the shape did not. The application subscription cost about twice its estimate, and Platform Management about an eighth: Log Analytics ingestion billed USD 0.00.
- The largest line is
rg-web-site-prod-cusStorage at USD 12.40. That isstsitefuncprodcus01, the Functions host storage (Standard_GRS). Its September metrics show the line is transactions, not capacity: 17.3 million operations (12.0 MGetBlob), 0.64 GB stored. It is named as the first line to look at. Functions, Azure Monitor and Cosmos follow at about USD 6 each. - The VALIDATION and OPEN_ITEMS lines record the gap and its drivers. The subscription budget now measures the workload, because the retired-resource tail has cleared.
- The Static Web App deploy keeps its deployment token, as an accepted risk
(#834). Owner decision 2026-10-03, recorded under Accepted risks in
TODO.md. - Today the
deployjob signs in with GitHub OIDC and reads the token just in time through theHCW Static Web App Deployerrole. It uses the token for one run, frommainonly, with a production approval. - Moving to the "GitHub" policy would not remove the token. Microsoft still
labels the token the recommended policy, and its identity-token example
still passes
azure_static_web_apps_api_tokenbesidegithub_id_token. - Compensating control: reset the token every 90 days or on any suspicion. Nothing stores it, so a reset breaks nothing.
- The comments in
deploy-azure-frontend.ymlthat called this open now point at the accepted risk. - Production deploys have two required reviewers. Since 2026-10-03 the
productionenvironment namessaulpatinojrandhcw-architect, and either can approve. Required-Inputs §4.4 and the comments in both deploy workflows said one. - The six-hourly
cp_sortDatehealer is gone (#816, part 2). Part 1 (#829, applied 2026-10-03) made the apply writecp_sortDateontocontentandblogswhenever it updates either container, and fail when one lacks it. With that holding, the band-aid comes out. .github/workflows/heal-computed-properties.ymlis deleted. No workflow writes computed properties any more.azurerm_role_assignment.github_deploy_cosmos_container_writerand itsdata.azurerm_role_definition.cosmos_container_writerlookup leaveinfra/oidc.tf, so the deploy identity holds no Cosmos control-plane write.infra/roles/cosmos-container-writer.jsongoes with them. The role definition was created by hand, outside Terraform, so the owner deletes it by hand after the apply.scripts/apply-computed-sortdate.mjsloses--applyand its ARM helpers.--inspectstays: it is the one check the apply cannot make, that every date alias in the data is ISO-sortable, and it now runs locally.--applyexits 2 with the usage text, which a test pins.scripts/assert-expected-plan.mjsdeclares the role assignment's delete and drops part 1's three declarations, which have applied. A declared delete must now name a resource thatinfra/*.tfno longer declares.- The runbook's computed-properties row, Required-Inputs (the custom-role
table,
CLIENT_ID, andCOSMOS_ENDPOINTandCOSMOS_RESOURCE_GROUP, now read by nothing), and the comments that described the healer as running now describe what is true. - App-settings values pass through
tostring()(#837). Theazapi_update_resourcethat stripsAzureWebJobsStoragefrom the Function App's settings now wraps each value intostring(). Every value is already a string, so the plan does not change. If a value ever comes back as an object or a list, the apply now stops with an error instead of writing it back in that shape. It was held back while T-513 was open, and T-513 closed as a name collision rather than a value problem. The comment ininfra/functionapp.tfno longer calls the hardening pending. - Comments no longer send readers to
TODO.mdfor open work (#821).TODO.mdstopped carrying work on 2026-09-05, but about 120 files still cited it, mostly as(TODO.md T-nnn). A reader who followed one found a file of accepted risks and nothing about the item. TODO.md T-nnnis nowT-nnnthroughout code, config and current docs. The T-items are closed; each one's record is in this file or in the commit that closed it..github/CONTRIBUTING.mdnow says so, and says that a comment pointing at open work names its issue.- Other
TODO.mdpointers now name what they meant: Required-Inputs §4.1, §4.2, §4.5 or §4.6, an ADR, or the changelog entry. Mentions that describe the file itself (the structure gate, the docs build, the accepted risks, the handling rules) are unchanged. So aredocs/history/and the dated architecture records, which are evidence kept as written. - The admin Forge Studio page no longer shows "(TODO T-518 / T-607)". The timers were armed on 2026-09-05, so it now says Auto-Forge is honoured by the scheduled forge timer.
- The five live gaps #821 listed were checked against the estate on
2026-10-02. One is still open: moving the Static Web App deploy off its
deployment token, now #834 (owner-gated, on the board). The other four
were already closed, and their comments now say how. T-513 was explained
as the
CORS_*name collision. The expected-plan assertion runs intfc-plan-check.ymlonTFC_TOKEN. The three stale workspace variables were deleted on 2026-09-02. TheHCWSite APIregistration exists and issues v2 tokens. -
infra/is touched in comments and one variable description only, so thehcw-azureplan carries no change beyond the permanent baseline. -
NVIDIA is the backup for content features, and a weekly probe records its speed (#701). Owner decision 2026-09-29. Through the AI Engine's Test, NVIDIA's free trial tier took 56-117 s to answer "ok" with the drafting limits. Capped at 16 tokens and 45 s (#806), it did not answer at all, so the delay is the tier queueing. As
firstfor content features, every synchronous call waited out NVIDIA's budget share (about 35 s, #807) before a paid provider answered. PROVIDER_PLACEMENT_DEFAULTS.nvidiainfunctions/src/lib/ai/ai-config.jsis noworderfor the eight content features: Gemini, OpenAI and Anthropic answer first, and NVIDIA serves when they cannot. Telegram wasorderalready. The public explain features and alt text stay lockedoff.- A stored placement still wins. A feature an administrator set to
First keeps it; the dropdown's
orderoption now reads "In order — the backup", in the owner's words for the decision. - The Test's core is
testProviderConnectioninfunctions/src/lib/ai/proxy.js, with no HTTP or auth. The Test button and a new timer both call it. - The timer is
probeAiProviders, Monday 06:15 UTC, infunctions/src/functions/schedulers.jsandfunctions/src/lib/timers/ai-provider-probe.js. It runs the Test on every provider with a key, one at a time, so NVIDIA's latency sits beside the paid providers', measured the same minute. It writesstatus,latencyMs,lastTestedandlastTestError, as the Test does, pluslastTestedBy: 'probe'. A timeout is recorded, not thrown. The cost is the Test's, a fraction of a cent a week; its usage rows have their own source,ai-engine:probe("AI Engine — weekly check"), andadmin_testis now a registered source too ("AI Engine — Test"). - It is flag-gated like every timer:
PROBE_AI_PROVIDERSis inlocal.timer_catalogue(infra/functionapp.tf) and theenabled_timersvalidation, disarmed until the workspace lists it.scripts/assert-expected-plan.mjsdeclares both runs ofFEATURE_FLAG_PROBE_AI_PROVIDERS, andverify-timer-witness.mjsrecords that it has no public witness. - The AI Engine card reads "Tested 3d ago by the weekly check". It now
shows the Test's error message too: the card read
lastError, while the Test has always writtenlastTestError, so a failed Test showed a red badge with no reason. - Tests: NVIDIA's own behaviour (request shape,
<think>stripping, 429 retries, pacing, timeouts, key verdicts, zero-cost usage, the time budget) is tested with NVIDIA placed first explicitly, because behind three answering providers it would never be asked. New tests cover the defaults, the probe, its timer wiring and the card.route-inventoryandtimer-schedules-utccount twenty-three timers. - Required-Inputs'
NVIDIA-API-KEYrow describes the placement and the probe, and.azure/api-surface.jsonnames the shared Test. - Vault auto-unseal is live on the lab host (#726). The owner accepted
the trade on 2026-09-29: root on the host plus the Arc identity can now
unseal Vault, and the five Shamir keys are recovery keys. The seal check
passed (the Arc identity reads
kv-labhybrid-prod-cus-01/keys/vault-seal), a cold copy was taken (/root/vault-before-726.tgz), andbootstrap.shswitched the seal. The owner ranvault operator unseal -migratewith three keys.systemctl restart vaultthen came backSeal Type azurekeyvault,Sealed false, with the journal readingunsealed with stored keyand no keys typed. The grant reads back as one row: Key Vault Crypto Service Encryption User for the Arc machine, on that key alone. - ADR 0032's amendment of 2026-09-29 is marked accepted.
lab-host/README.md,docs/architecture/labs-host.mdand Required-Inputs record the seal as on. The architecture record also records Vault as on since #729.- The Docker Hub docs say it is live. Three documents still described Docker Hub publishing as waiting on the owner:
docs/runbooks/docker-hub-publishing.md(its status box and "Follow-ups once it is live");lab-image/README.md(the introduction and "From Docker Hub");docs/standards/required-inputs.md(theDOCKERHUB_*rows and §4.11).
All three now record the connection created on 2026-09-29, the first run
(36516945081), the matching digests and the verified provenance. The site
pulls from Docker Hub (#795), and #779 is closed.
- The agent sandbox recipe moved from /education/labs to the Docker
hub, at /docker/sandboxes (#774). The "Run an agent against your
landing zone" section (SandboxSection, #676) is now the body of a new
page, "Run an agent in a sandbox" (pages/docker/SandboxesPage.jsx),
under a heading that keeps its old id, agent-heading. The page says
when its commands were last checked against Docker's documentation
(29 September 2026). Before the move they were read again and still
held: sbx run creates a sandbox whose name is new and reattaches with
--name, -t/--template takes a container image, and --cloud
sandboxes need a Docker Agentic Platform subscription, mount no host
folder and expire after an hour by default. On /education/labs the
agent slot stays with its heading, so an old
/education/labs#agent-heading link still lands on it, and holds one
line pointing to the new page. The Docker landing page's third focus
area reads "Available now" instead of "Coming soon" and links there,
as does the Docker tools page. The Docker hub's header gains
"Sandboxes", in the place Terraform's has "Modules". sandboxes is a
page type in routeFactory.ts, so the header recognises the static
route as a Docker page. The page is in STANDALONE_ROUTES, so it
pre-renders, and it is in validate-provider-pages.js. The issue keeps
live-check.
- The labs' "Run it locally" commands pull from Docker Hub.
LAB_IMAGEinfrontend/src/data/labs/catalogue.jsis nowhybridcloudworks/hcw-lab:latest. The image went live there on 2026-09-29 through Docker's OIDC connection (run 36516945081, #779): - its digest is the same one GHCR serves;
- its SLSA provenance verifies under both registry names;
- an anonymous registry token reads its manifest.
ghcr.io/hybridcloudworks/hcw-lab:latest stays a mirror of the same
digest, and the lab image README lists both.
- CI: every workflow runs on Ubuntu 26.04, named, not ubuntu-latest
(#794).
Owner rule 2026-09-26: the newest LTS OS. GitHub moves ubuntu-latest
from 24.04 to 26.04 over 2026-10-19 to 2026-11-19
(actions/runner-images#14748), so for a month the same job could land on
either image. All 36 runs-on lines in the 23 workflows now say
ubuntu-26.04, which GitHub made generally available on 2026-09-17.
Pinned rather than guarded: a check that failed while ubuntu-latest
trailed the newest LTS could only be fixed by pinning, and during the
rollout it would pass on one run and fail on the next. The pin is held by
the version floors (#715). scripts/lib/version-pins-workflows.mjs reads
each job's runs-on as an Ubuntu pin, and a label with no release
(ubuntu-latest, ubuntu-slim), an expression or a runner group is a
finding. scripts/version-floors.json gains
kinds.ubuntu.platformCeilings.runners, the newest LTS GitHub-hosted
runners offer as generally available, which every runs-on must equal.
It is a ceiling beside the Ubuntu floor because GitHub ships a new LTS's
runner image months after Canonical releases it (26.04: released
2026-04-23, preview 2026-06-11, generally available 2026-09-17). When
28.04 is released the floor moves and the lab host goes red at once;
runs-on goes red only when update-version-floors.yml, which now also
reads the "Available Images" table of the runner-images README, finds
28.04 there without a preview or beta badge. An unreadable README leaves
the ceiling where it is, with a warning, as an unreadable Learn table does
for the functions ceiling. Tests cover the reader (release labels, quoted,
-arm and in flow lists; ubuntu-latest, ubuntu-slim, expressions and
groups refused; other systems skipped), the rule (a label behind the
ceiling and one ahead of it both refused; runs-on green on the ceiling
while the floor is ahead), a gate that every runs-on line in every
tracked workflow was read, and the updater (holds while GitHub has not
made the new LTS generally available, moves when it has, never past the
floor, never to an interim release, never down, soft on an unreadable
README). .github/actionlint.yaml declares ubuntu-26.04, because
actionlint 1.7.12, the newest release, predates it (rhysd/actionlint#743
adds it), and a test fails a declared label no workflow uses.
deploy-azure-frontend.yml's canonical-link check reads find -print0
rather than looping over $(find …) (shellcheck SC2044, already on main),
checked against a fixture path containing a space. What 26.04 changes
for the tools the workflows use: preinstalled Node.js 22.23.2 → 24.21.0,
which only publish-lab-image.yml's sandbox check and the four scheduled
pull-request commit jobs run on (built-in modules only); system Python
3.12.3 → 3.14.4, which no job uses (each sets up its own); Docker
28.0.4 → 29.4.2 and Compose 2.38.2 → 5.1.3; jq 1.7 → 1.8.1; and
coreutils now Ubuntu's Rust coreutils (uutils). Azure CLI 2.90.0, GitHub
CLI 2.101.0, PowerShell 7.6.6 with Pester 5.9.0, Ansible 2.21.4 and
Docker Buildx 0.37.1 are unchanged, and Playwright 1.63.0 lists
ubuntu26.04-x64 for install --with-deps. Miniconda, Julia, Swift,
Mercurial, Pulumi and Java 8 are gone from the image; no workflow used
them.
-
Validate on the lab: a report for learners, not the job log (owner request 2026-09-28). The owner's first public run from
/tools/landing-zoneendedSuccess! The configuration is valid., but the page printed the raw job log under it: the image pull with its registry, digests and layers, the capability'srewrote module … -> ../../../opt/avm/…block,terraform initlisting those paths again, and every provider ending(unauthenticated).GET public/labs/jobno longer answersoutput; it answersreport, built by the newfunctions/src/lib/labs/visitor-report.js: the verdict first (valid,invalidorerror, with a plain-wordsheadline), Terraform's own error text for an invalid configuration with the learner's file names and line numbers kept (a path into the lab's copy of a module is cut back to<module>@<version>/<file>), Modules used asname@versionfrom the rewrite lines with one line saying the lab uses offline copies of the Azure Verified Modules so validation needs no internet, and Providers asnamespace/name vX.Y.Z. The log is read by allow-list, so a line nobody has seen yet is dropped rather than shown, and a Terraform error that still names the runner after scrubbing is dropped whole and the headline says some detail could not be shown. The raw output stays on the job document, so the admin Jobs view is unchanged. The builder renders the report and never anoutputfield, even when one is present, and reads the report defensively (a known verdict, entries shaped as modules and providers, no runner text). Tests:visitor-report.test.jsover three fixtures made from the owner's log (the passing run, the same run with Terraform errors injected, and a job that failed before Terraform ran), plus noise injected at every line;public-visitor-copy.test.jsdrives those logs through the job route and fails onghcr.io,sha256:,docker,Pulling,/opt/,../../../,hcw-lab-runner,image,registry, the host's names or(unauthenticated), with its own token list;public-copy.test.jsgains alabRunnerterm; andLzLabValidate.test.jsxrenders what the server's builder makes of the same logs, so the frontend row of CI also runs when those two functions paths change. Also: the owner seededTURNSTILE-SECRET-KEYon 2026-09-28 and it resolves, soTURNSTILE_SECRET_KEYis offEXPECTED_UNRESOLVEDinscripts/check-unresolved-secrets.mjs(an unresolved one now pages like any other), and Required-Inputs marks it SET. -
Public pages speak to visitors only: no vendor names, no admin-side messages (owner direction 2026-09-28). The owner's words: "Scrub all user-facing pages; remove references to backend workings. The user only knows the front end and doesn't know about tools we use (i.e. Cloudflare, etc.), or when you mention items that the admin needs to resolve." On the Landing Zone Builder the AI explanation's label now reads "AI-generated summary. Check the module documentation before relying on it." (the pricing comparison's, "…Check the sources…"), and the model name, time and cache note under it are gone;
POST public/cloud-tools/explainno longer returnsmodel(the stored document and a log line keep it), and every refusal carries a code (EXPLAIN_UNAVAILABLE,EXPLAIN_RATE_LIMITED,EXPLAIN_PAUSED_FOR_TODAY,EXPLAIN_EMPTY,EXPLAIN_FAILED) with a visitor sentence. "Validate on the lab" says "Validation on the lab isn't available right now. You can still download the files and validate locally." for every closed door, "The lab is busy right now…" for a full queue, and words the browser check, the limits and every refusal without naming Cloudflare or a setting; the server'sDOOR_REASONS,LOCK_REASONSand the newLIMIT_REASONSsay the same, the machine codes (TURNSTILE_NOT_CONFIGUREDand the rest) stay for logs and the admin pages, and the frontend maps each code to its own words and never rendersreasonorerror, nor the code (data-dooris nowclosed/busy/open). The labs page drops the host's vendor, the API host, "not provisioned", "endpoint is not published" and the issue numbers of unbuilt sections; the pricing, price-changes and labs status cards say a read failed without the error's text; the submission pages lose the "View Queue" link into/adminand the admin-queue wording, andsubmitPublicContentthrows only visitor sentences; the frameworks, code and coder-corner pages stop naming ContentForge and Framework Studio; androbots.txtstops naming the host. Two tests keep it that way:frontend/src/public-copy.test.jsparses every public page, component and hook (admin excluded) and the static files, and fails on a vendor or admin-to-do term outside a per-file allow-list of subject matter and the legal pages;functions/src/lib/public-visitor-copy.test.jsdoes the same for the visitor-facing strings of every module behind apublic/*route and drives the lab and explain refusals through the handlers. CI's functions row now also runs when the three frontend files it reads change. -
Only the site can frame the lab; a direct visit goes to
/education/labs(owner decision 2026-09-28). The owner's words: "The lab should only be accessible through 'panes' from my site, lock to that." A newlab_panes_onlysnippet in the caddy role's Caddyfile is imported into the site block ahead of every route. It coverslab.hybridcloudworks.com, every*.labname,coder.laband*.coder.lab, and every response, including the 404 and any route added later. Each response carriesContent-Security-Policy: frame-ancestors 'self' https://hybridcloudworks.com https://www.hybridcloudworks.com, with noX-Frame-Options.wwwis listed because it serves the site with a 200, and'self'is there for code-server's same-origin webviews. The named matcher@lab_direct_visitsends a top-level browser visit tohttps://hybridcloudworks.com/education/labswith a302. That isSec-Fetch-Dest: document, orSec-Fetch-Mode: navigatewith no destination. Panes (iframe), fetch, XHR and WebSockets (empty), and subresources pass. So do clients that send no fetch metadata: Coder's agents and CLI, curl, and the Function App's status proxy, whose NodefetchsendsSec-Fetch-Mode: corsalone. The Coder route (10-coder.caddy.j2) removes Coder's defaultframe-ancestors 'self', which would otherwise keep Coder out of the panes. It also lets/api/v2/users/oauth2/github/callbackthrough at the top level, because GitHub cannot be framed. That path only redirects, and the redirect that ends sign-in lands on the labs page. The values arecaddy_frame_ancestorsandcaddy_direct_visit_redirectingroup_vars/all.yml. The Coder owner steps inlab-host/README.mdnow check from the CLI or in a pane, andcoder logintakes a token. The decision is recorded indocs/architecture/labs-host.md, "Panes only", with a one-line pointer in ADR 0032 decision 4. It takes effect on the host with the nextbootstrap.shrun. -
Lab jobs on the newest images:
shell-echoon Alpine 3.24,ansible-checkonhcw-lab-runner; every job image held to a floor (#747, #715). The first real lab job (2026-09-28,shell-echoonvps-hostinger-01) pulledalpine:3.20, past its end of life on 2026-04-01.vps-agent/lib/capabilities.jsnow pinsalpine:3.24.2by its index digestsha256:294b683c..., the value the registry'sDocker-Content-Digestheader,docker buildx imagetools inspectand the Docker Hub API all reported on 2026-09-28 (3.24.2 was endoflife.date's newest release, 3.24 supported to 2028-06-01).
ansible-check moves off alpine/ansible:2.17.0, a third-party image on
ansible-core 2.17 and Python 3.12 that never ran a job under the sandbox:
its HOME is /, so ansible-core stopped at Unable to create local
directories(/.ansible/tmp): [Errno 30] Read-only file system before
reading the playbook. It runs the same argv on the pinned
hcw-lab-runner (ansible-core 2.21.4 on Python 3.14.7), with the job
tmpfs for ANSIBLE_LOCAL_TEMP and ANSIBLE_HOME=/tmp/run/ansible-home
so no warning about ~/.ansible reaches the output. The runner carries
ansible-core and no collections, so a module outside ansible.builtin
is refused with couldn't resolve module/action, as the job type's
description now says. IMAGES.ansible is removed, and
capabilities.test.js fails an IMAGES entry no capability uses.
The floors check now reads IMAGES. scripts/version-floors.json gains
an alpine kind (endoflife.date alpine-linux, newest line, N-2
patches: 3.24.2, floor 3.24.0), which the weekly updater moves like
Python's; a digest-pinned Alpine reference must name its exact release,
because a digest does not float. An IMAGES entry that no kind governs,
that the repository does not build (hcw-lab-runner is judged through
lab-image/Dockerfile) and that no unsourced entry names fails
version-floors.test.mjs by name; against main's map it reports
3.20 is below the floor 3.24.0 and alpine/ansible has no floor.
lab-image/sandbox-check.mjs now runs every capability, with a case per
capability enforced: shell-echo on its pinned alpine digest, and
ansible-check on three new fixtures under
lab-image/smoke/ansible-check-payload/ (a valid playbook, an unknown
play keyword, a collection module). The admin Labs Settings tab no longer
tells the owner to pre-pull alpine:3.20, hashicorp/terraform:1.9 and
alpine/ansible:2.17.0.
- The lab agent and the Coder template pull the image that vendors every
builder module; lab jobs get 512 MB; the lab articles match (follow-up to
#742).
vps-agent/lib/capabilities.jspinshcw-lab-runnertosha256:8cc69354...andlab-host/coder/templates/hcw-lab/main.tfpinshcw-labtosha256:014a81d6..., the digests main's publish run 36320771780 pushed for 7c0a95b2 (#742), both checked withdocker buildx imagetools inspect. Those images carryavm-res-network-virtualnetwork@0.22.2, the version every spoke calls, so the Landing Zone Builder's full default build validates offline.
Job memory moves from 256m to 512m (owner decision 2026-09-27) in the
agent's default (vps-agent/index.js), vps-agent/.env.example and the
labs_agent role's labs_agent_job_memory and argument spec. Measured
on Docker 29.8 against the pinned runner, under the agent's own sandbox
argv: at 256m the full default build's terraform validate is OOM-killed
(exit 247) after an offline init that succeeds; 320m passes, but in 20 to
86 s across four runs, and 512m in 15 to 19 s every time. One job runs at
a time on a 16 GB host, so the margin is cheap; the reason is recorded
beside the default. sandbox-check.mjs
loses its BUILDER_LIMITS override and reads its limits from
vps-agent/index.js, so every job, the builder's included, runs at the
agent's own limits; it passes all six on the pinned digest.
publish-lab-image.yml now runs on pull requests that change
vps-agent/index.js for that reason. A new
scripts/lab-job-limits.test.mjs fails when the agent's defaults,
.env.example, the role defaults and the role's argument spec disagree
about any job limit; the scripts (operations) CI filter now covers
vps-agent/index.js, .env.example and lib/capabilities.js, which
lab-job-types.test.mjs already imported.
The lab article drafts no longer say a build with landing zones cannot
init offline. Part 1 keeps its platform-only build, with order as the
reason and a pointer to the full build. Part 2 lists every builder module
as vendored, shows the full default build's spoke rewrite and its 71
module lines with none downloaded, and turns the left module
"spoke_corp_1" entry into what it now means: an image published before
2026-09-27. Every quoted line was re-run against the new images.
-
Coder's PostgreSQL moves from 16.15 to 18.6, with the move rehearsed (#715). 18.6 is the newest release of the newest major (18, supported to November 2030; read 2026-09-26). It is pinned by index digest
sha256:5a5a84b1...2da9722, whichpostgres:18andpostgres:18.6both resolve to, and which the registry'sDocker-Content-Digestand the Docker Hub API confirm. The 18 image keeps its cluster in/var/lib/postgresql/18/dockerunder aVOLUMEat/var/lib/postgresql, and refuses to start with a mount at the old/var/lib/postgresql/data, so the Compose volume now mounts at/var/lib/postgresql. The host holds no Coder data, so nothing is migrated. Rehearsed on Docker 29.8 with the committed Compose file and the pinned Coder v2.37.3. Coder applied its migrations to 585, the newest v2.37.3 ships, and/healthzanswered 200. An owner account was created and dumped with the role's backup script, rendered from its template. The dump was restored into a fresh 18.6 with the README's drop, create and load, and a second dump matched it line for line. Coder then started on the restored database, with/api/v2/buildinfoand/api/v2/users/firstanswering 200. The coder role README's "stay on 16" paragraph is replaced by that record and a procedure for the next major: dump, set the old cluster aside inside the volume, bump, restore. Its two host commands were run against the Compose file: the 18 image refuses to initialise beside another major's cluster, and initialises a fresh cluster once that one is set aside. The backup and restore commands need no path, so they are unchanged. Where the database runs is now recorded as an owner decision (2026-09-26), in the role README's "Where it runs" and as a one-line amendment to ADR 0032's decision 2: it stays this container on the host. The two options not taken were Coder's built-in PostgreSQL (binaries fetched from Maven at start, outside the host's digest pins) and Azure Database for PostgreSQL Flexible Server B1ms ($0.01921 an hour in Central US, about $14 a month in compute, plus storage and internet latency from the VPS). -
The hcw-lab image runs Python 3.14.7 on Debian 13 trixie, with every tool at its newest release (#714, #715). Owner rule 2026-09-26: every runtime, OS and base image the repository chooses is on its newest supported release. Every stage of
lab-image/Dockerfilemoves fromdebian:bookworm-slim(Debian 12, Python 3.11) to the officialpython:3.14.7-slim-trixie, pinned by index digest;versions.envrecords why one base serves every stage (the python image is debian:trixie-slim plus CPython, and vendor-avm.sh then runs on the same Python the runner ships). ansible-core moves from 2.19.13 to 2.21.4, the newest line, whose controller range is Python 3.12 to 3.14; its dependencies are no longer Debian packages built for another interpreter but eight wheels pinned inANSIBLE_CORE_DEPSand installed in pip's hash-checking mode, which also fails the build on any dependency not listed. No Debianpython3package and nopipcommand remain in the image. The Azure CLI stays at 2.90.0, the newest release, now written asAZURE_CLI_VERSIONplusAZURE_CLI_SUITE=bookworm: packages.microsoft.com has no trixie suite and Microsoft's install page says to use the latest Debian suite in that case, and the apt source is the deb822 form that page documents. Terraform 1.16.4, kubeconform 0.8.0, helm 4.3.0, kubectl 1.37.1 and the mirrored providers (azurerm 5.7.0 and 4.81.0, azapi 2.12.0, alz 0.22.0, random 3.9.1, modtm 0.4.0, time 0.14.2) were already the newest releases, each sum re-checked against its publisher's file. The sandbox template's Azure CLI comment now says what it installs, the-1~resolutebuild from its Ubuntu 26.04 base's own suite, and records why the recipe adds no Python. It also gainsHEALTHCHECK NONE, as the lab image has: the base defines no healthcheck, so nothing changes at run time, and it clears the checkov CKV_DOCKER_2 / trivy DS-0026 finding that Qlty reported as blocking once the file was touched. The publish workflow's digest guard, which grepped^FROM debian:, now checks everyFROMin both Dockerfiles: an earlier stage, or exactly theimage@digestversions.env records for that file (BASE_IMAGE/BASE_DIGEST, and the newSANDBOX_BASE_IMAGE/SANDBOX_BASE_DIGEST), and fails on anything else.smoke.shnow also comparespython3 --versionwithBASE_PYTHON_VERSION. Measured with Docker 29.8.0: runner 1.70 GB / 319 MB to 1.71 GB / 319 MB, full 2.69 GB / 491 MB to 2.73 GB / 497 MB (on disk / compressed), sandbox template unchanged at 3.38 GB / 797 MB. Both smoke tests andsandbox-check.mjspass on the new images. The pins invps-agent/lib/capabilities.jsand the Coder template move in a follow-up, from the digests main's publish run reports. -
The six radarlint-python findings left on
mainare fixed, not silenced. #588 counted nine on 2026-09-14 and closed with them outstanding; three had gone with later changes, andqlty check --all --filter radarlint-pythonon currentmainreported six, all in files no suite exercises for style. Each is a small extraction, and each keeps the behaviour a test or CI step pins:tooling/workflow.pygainsapply_node_policy(the required-or-skipped decisioninitmade inline) andscore_nodeplusoverall_status(the per-node scoringevaluate_workflowdid in one loop), which brings both functions under the cognitive-complexity limit while the CI round trip — smoke closesempty, dirty refuses then closesabandoned, the handoff validator accepts real work and rejects a wrongagent_idand missing evidence — passes unchanged.scripts/docs/check_redaction.py'smainsplits intoline_findingsandscanwith the same output, and the gate still passes over the same 72 files.scripts/docs/hooks.pynames itsdocs/prefix once, and its link rewriter computes the target in one place and returns once — the six-return function Qlty flagged on this very change, fixed in the same pull request rather than carried.scripts/complexity/report.pyreads the delta arrow from adirectionfunction instead of a nested conditional. And the Stop guard test's audit-log reader is a module function, which is what the rule wanted from aTestCasemethod that is not a test. Qlty now reports no issues for the plugin; ruff,compileall, the 13 hook tests and the strict docs build are all green on the result. -
TODO.mdindexes the one open issue, not the nine it had outlived. Its "Where the open items live" table still pointed at #433, #434, #435, #436, #442, #349, #358 and #454, every one closed since 2026-09-09, beside the one row that was still true. Owner decision 2026-09-18: #432 is the only issue that stays open — the audio pipeline's three remaining secrets wait on the ElevenLabs and RSS.com Max purchases — and the five others open that day (#501, #567, #588, #611, #613) were closed with a comment on each recording what was outstanding. The table now carries #432 alone and says where the residual went. Nothing else in the file changed: the accepted risks and the handling rules stand as written. -
The deployment runbook names the directory the estate lives in, and the
az login --tenantline that reaches it. On 2026-09-18 a plainaz loginfrom a fresh Mac landed in the owner's home tenant,hybridcloudworks.com, which holds no subscriptions, and closed withThe subscription of 'b9e02281…' doesn't exist in cloud 'AzureCloud'— a line that reads as a missing subscription and is a wrong-directory sign-in. The four subscriptions live in the tenant named Default Directory, where the work account is a guest behind MFA, so only an explicit--tenantreaches them. The runbook now says so, gives the sign-in line by the tenant'sonmicrosoft.comdomain rather than its GUID (the docs redaction gate keeps real GUIDs out of published pages), and shows how to read the GUID back from the session when a variable needs it. -
The frontend suite runs on vitest's
vmThreadspool, and three test files stopped depending on which pool they get (#645). Building a jsdom per test file — 172 of them — was half the suite's tracked time. One V8 context per worker, reused, takesnpm testfrom 84.6 s to 25.0 s andnpm run test:coveragefrom 100 s to 57 s, on the same 4-core machine. CI pays that twice, in thefrontendjob and incoverage (qlty).
Not isolate: false, which vitest suggests in the same breath. The two
are not the same trade. Two test files — one mutating a shared module object
and setting a global, the other reporting what it inherited — showed that
under isolate: false, once both land in the same worker, the second sees
the mutation and the global. Module state crosses file boundaries there,
which is exactly the bug #640 was filed as and could not reproduce: passing
alone, failing in a full run. It would be nondeterministic too, since whether
two files share a worker is a scheduling detail — with only two files they
usually do not, so a casual check of isolate: false looks clean. vmThreads
keeps per-file isolation; the same probe in the same worker saw a fresh
module registry and no global.
Three files had to stop leaning on the old pool first, and each was wrong
independently of it. scripts/audit-published-pages.test.js now declares the
node environment it always needed — it tests a Node CLI module, touches no
DOM, and was failing at collection, so its 15 tests silently did not run
while the suite still reported green. setupTests.js states the compression
streams a browser provides and jsdom does not implement, which the draw.io
parser uses and which only worked before because Node's globals leak into a
worker. And AuthCallbackPage reaches its one hard navigation through
@/lib/hardNavigate, so its test uses a real URL instead of a fabricated
Location — a fabrication that had already caused two bugs of its own, both
recorded in that file.
Checked rather than assumed: 2,272 tests under both pools, and coverage
identical to the same numerators and denominators. The two lcov.info files
are not byte-identical — same 36,985 records, none missing from either, but
398 differ in hit count and none flips covered to uncovered or back,
which is why the percentages match exactly and why Qlty, which gates on
coverage status rather than on how often a covered line ran, reports what it
did before. Nor is the pool the cause: two back-to-back runs of the same
pool differ in 164 hit counts, also with no flips. Hit counts are run-to-run
nondeterministic here whatever the pool. Peak Node RSS is 3,213 MB against forks' 1,154 MB across a
full coverage run — 2.8×, the real cost of reusing a context per worker, and
the number to watch as the suite grows. #645 had listed memory as
unestablished.
Fixed¶
- One Podbean publish per transcript, even when two schedules land at
once (closes #1118). An approval (
POST cms/podcast/transcripts/review) and a retry (POST cms/podcast/transcripts/{id}/publish) arriving in the same instant could both read the transcript with no publish pending, both queue a job, and two first runs would both create: the episode twice on the public feed. #1117 reconciled a create whose answer was lost; this is the other half, the reservation. - The pending marker is now etag-conditional.
scheduleHostPublishwriteshost.podbean.pendingwithpatchDoc'sifMatchset to the_etagthe document was read at — after an approval, the one the status write returned — so it lands only on the version the decision was made about. A 412 means someone else wrote first. The document is re-read once: a live reservation on it is theirs, and the loser answers in flight naming their job (202 from review, 409 from the retry) and cancels the job document it wrote, so the stale-queued sweeper can never re-enqueue it. Anything else — the other job already finished, a withdrawal, an unrelated edit — is not a publish in flight, so the reservation is tried once more against the fresh document, from its stable record, so anepisodeIdjust stored is kept under the marker. A document that moves on every attempt queues nothing and throwsRESERVATION_CONTENDED; the review handler recordsSCHEDULE_FAILEDand keeps the approval, as it already did for any other failure to queue. A transcript handed over without its_etagis refused before any write. Nothing is enqueued unless the reservation landed. -
Tests. Two schedules racing on one document produce one job, one marker and one message, the loser in flight; an approval and a retry racing through the handlers agree on one job id; the fresh-document retry keeps the stored episode id; a reservation lost twice cancels its job and queues nothing; a document without an etag writes nothing.
-
Listen & Learn no longer renders dead players for audio the site stopped serving (#1119). Every public Listen & Learn row (the AB-100 set) still pointed at the pre-Azure Firebase bucket, and every file there answered 404 on 2026-10-13, so the audio page showed three players that played nothing and the browser's policy refused the host besides.
servableListenAndLearnAudioUrltreatsstorage.googleapis.comandfirebasestorage.googleapis.comas retired: the provider-wide list and the section counts omit such a row, as they omit one with no audio, and the per-certification read blanks itsaudioUrlso the page shows the chapter without a player.-
The rows themselves are kept. Regenerating a chapter (Admin → Listen & Learn → Regenerate) writes new audio to blob storage and the row returns.
-
The podcast pages show the show again, now that it is back on Podbean. The public list returned one stale RSS.com upload instead of the three episodes in the feed. Two causes, both from the move on 2026-10-13:
- The hide rule.
RETIRED_PODCAST_MEDIA_HOSTShid every episode onmcdn.podbean.com(#372). That was right when the show left Podbean on 2026-09-05 and its files 404'd, and wrong once the show returned: every current episode is on that host again. Each host the show has left now carries the moment it left. A row there is hidden only when nothing has refreshed it since.fetchPodcastFeedsupserts every episode in the current feed every two hours, so current episodes show. Rows from Podbean's first tenure, and the RSS.com upload no feed carries, stay hidden. A host the show still uses is never hidden by this rule, so a stalled timer cannot empty the page. The section counts projectupdatedAtso the sitemap counts the same rows. -
media-src. Once Podbean had copied the audio, an enclosure wentmcdn.podbean.com→ 302 → a numbered storage host (s307,s396,s405.podbean.com, one per file). #1107'smcdn.podbean.comalone failed at that hop in headless Chromium, andhttps://*.podbean.comloads it. RSS.com's three hosts left the list, since no listed episode is on them. -
Show episodes play on the site again, from RSS.com and from Podbean. The site's
media-srcallowed RSS.com's two media hosts (#1101), but an RSS.com enclosure redirects throughrsscom.pdn.tritondigital.com, RSS.com's download measurement, before the file. Chromium checksmedia-srcon every redirect hop, so every show episode failed at that hop (MEDIA_ERR_SRC_NOT_SUPPORTED), measured in headless Chromium against the live policy on 2026-10-13. - The show moved to Podbean the same day: RSS.com's feed now answers 301
with the Podbean feed, all three episodes keep their GUIDs, and the
site's ingest follows the redirect. Podbean's enclosures start at
mcdn.podbean.comand, until Podbean has copied the audio, redirect back through both RSS.com hosts and the measurement host. media-srcadds both hosts, and nothing broader. With them, an RSS.com episode and a Podbean episode each load in Chromium (611 s).-
csp.test.jspins the list and says to trace an enclosure's hops withcurl -sILbefore changing it. -
The read-only Azure identity writes nothing now: the content manifest's origin window has an identity of its own (#1098).
github_readerheld HCW Function Settings Reader and, forpublish-content-manifest.yml's per-run origin window, HCW Function Config Refresh. RBAC is additive, so that one principal could list the Function App's app settings and write them, from any step of the hourly monitor, the secrets monitor or the manifest job onmain.infra/oidc.tfsaid it "can change nothing". github_manifest_windowholds Config Refresh onfunc-site-prod-cus-01and Reader onrg-web-site-prod-cus, and nothing else, with main-branch sign-in in both subject forms. Reader sits at the group, the reader's own proven shape, so the subscription is discoverable at sign-in; it lists nothing. The manifest job signs in withMANIFEST_WINDOW_CLIENT_IDand skips until it is set.github_reader_origin_windowis removed, so the reader holds Reader and Settings Reader onrg-web-site-prod-cusonly. The comments that said it could change nothing are true now, andscripts/oidc-subjects.test.mjsfails if either identity gains a grant.scripts/set-github-variables.ps1seeds the new variable, andALERT_READER_CLIENT_ID, which #1053 left out of it. The plan checker declares the five creates and the one delete.-
The lab-host bootstrap tests made their temp folders in the suite body, which Vitest runs even for a skipped suite, while it never runs a skipped suite's
afterAll. Every Windows run left a folder behind. They are made inbeforeAllnow. -
Cosmos restore: the first drill passed, and the runbook's step 5 now works as written (2026-10-13). The owner restored the latest backups (
full/2026-10-11+ 1 delta, 11,971 documents) into a scratch account, and every one of the 62 containers matched the backups document for document. The site's local API served 24 content items and 6 podcasts from it. - RTO 1 h 39 min against 8 h; RPO 22 h against 24 h. That time includes finding and fixing three restore-script bugs (#1102).
- The scratch account, the firewall rule and the read role are gone. The
row is in the Drills table, so the daily
Check drill agerun passes; the next drill is due by 2027-01-21. - Step 5 needed two things the page did not have. First, a storage
emulator: without Azurite the local host fails every timer listener and
exits. Second, a Node count instead of
Invoke-RestMethod: content documents carry keys that differ only in case (Title/title), which PowerShell's JSON reader cannot hold, so its count printed 0 against a correct answer. Both are now in the step, with Azurite's clean-up in step 6. -
check-drill-age.test.mjsread the runbook's table as of a fixed 2026-10-07, which turned the first real row into a date from the future. It now reads it as of today. -
Cosmos restore: a restore with a daily backup in it completes (found by the first drill, 2026-10-12). The restore script asked every backup layer for every container in the Sunday full. An operational container such as
admin_audit_logsis in the full and in no daily delta, by design, so the first delta stopped every restore as "corrupted". No restore that included a delta, which is every day but Sunday, had ever completed. - A layer now contributes only the containers its own manifest lists, and a listed container with no file still stops the restore.
- Against the live backups the dry run reaches the end: 11,971 documents across 62 containers.
- Each layer's manifest is checked before anything is written: it must name its own run, and list its containers by name. A missing or malformed list stops the restore instead of quietly dropping that day's changes.
--verifyexpects each container to hold what it held before the run plus what the run created, and counts an empty container as 0. The drill's first report flagged 18 false mismatches: 17 empty containers, and one an aborted first attempt had already filled. An independent check found all 62 containers in the test database exactly matching the backups.- The script refuses a target whose account name has
-prod-in it unless--allow-production-targetis given, because a restore writes backup copies over whatever the target holds. -
The runbook installs
funcbefore step 5, takes the container count from the spec instead of a stale 73, explains Azure'sEnqueued/Dequeued/Succeededlines, and deletes the local settings file at teardown. -
Lab host: every bootstrap publishes every Coder template, with no token (2026-10-12). The
coderrole's push publishedhcw-labalone, with the host's self-renewing rotation credential.amo-guided-labandna-guided-lab(#1090) could only be published with the owner's token, so each change to them was a manual chore, which the owner had already ruled out on 2026-10-09. hcw-coder-automation push-templatepublishes every directory underlab-host/coder/templatesthat holds a*.tffile,hcw-labfirst, each by name and each tracked on its own digest. One failing does not stop the next, and the next run retries only the one that failed.hcw-labkeeps the state fields the daily report and the site's drift check read. A state written before this change still counts it as published.-
The lab-image digest pull request told the owner to fetch a token and push the template after the bootstrap. It now says the run publishes them, and a test keeps the token instructions out and checks the task it names still exists. The runbook's "After a merge" and
lab-host/README.mdnow say the same, with the manual helper kept as the fallback for a host without a working credential. -
Lab host:
bootstrap.shruns one at a time (#1069, part 2). Two concurrent runs were not serialised across the firewall rebuild's guard-to-unguard sequence, or across two checkouts of/opt/hcw-src. - The script takes a
flockon fd 9 straight after its root check. A second run waits up to 30 minutes, then refuses having changed nothing. - Every pass proves it holds the lock rather than trusting a marker. A
pass handed over by
execreuses its inherited fd 9 only when that is the lock file itself. A handover from an olderbootstrap.sh, or a hand-setHCW_BOOTSTRAP_HANDED_OVER, takes the lock like any other run. - The play inherits the lock, so it holds until every mutator has ended, even if the script is killed first.
- The lock file is
/run/hcw-bootstrap.lock, where only root can create files. It is created0600and refused if another user owns it, so no local user can open it and hold the lock. It is opened without truncating, a symlink in its place is refused, and it is never removed. The wait must be a whole number of seconds. -
Verifying add-on image attestations before the pull (part 1) waits on an owner decision on #1069.
-
Reminders: an editor's save keeps the credential register's rows, and their stamps, beside a malformed owner row (#1041). The save validated the stored sheet as a whole, so one owner row that did not validate (an out-of-band edit) made it keep no register rows at all. Their
notifiedstamps went with them, and the nextsendRemindersrun said a stage again. Each stored row is now normalised on its own: an invalid row is left out, the valid register rows are kept as stored, and a second row under an id already kept is dropped. -
Forge Studio: "Send to In Review" sends on the first click after the document loads (#1072).
useForgeSession's operations read the session through a ref that moved in a passive effect, after paint. A document that arrived from a read, not a click, was on screen while the ref still held the session without it. A click in that gap made Send to review send nothing, and a Save could have sent the previous ETag. The coverage run, being slower, hit the gap, and the page test flaked there. The ref now moves in a layout effect, inside the commit that renders the state. A hook test calls Send to review in the commit that first renders the document: it resolvednullbefore and posts with the ETag now. -
Lab agent: a job's result is written only while the agent still holds the job (#1057).
completeLabJobread the job, checked that the reporting agent held it, then wrote the result with an unfenced patch. Three things can happen to a job in between: an operator cancels it, the canary withdraws it as overdue, or another agent claims it once the 15-minute lease lapses. When one did, the stale agent's result overwrote it. The write is nowreplaceDocIfMatchunder the ETag of the read that found the job held. On a 412 the job is read again and the same rules decide: - still this agent's and
claimed: the write is retried, at most three attempts in all; - anything else: the agent gets 403, 404 or 409, and the job is left as it now is.
The agent already treats a refused completion as an error it logs, so it needs no change.
- Learn catalogues: the weekly updater passes its own check again, and a re-read can land before the vendor's day (#1091). Two fixes:
- The weekly updater went red on 2026-10-12, its first run after the
2026-10-05 re-read. That re-read's test pinned the Azure
DATA_AS_OFto exactly2026-10-05, andupdate-learn-catalogue.ymlstampsDATA_AS_OFon every run, so the refresh could never pass. The test now holds2026-10-05as a floor for all three catalogues. - #1091 asked for AB-400 (available 2026-10-16) and PL-400 (last day
2026-10-30) to be re-read before their days, but a stored status
could not move early without the badge moving with it:
activeshowed AB-400 bookable four days before Microsoft opens it.deriveStatusgains rule 2. A row whose newstatusReadDateis before its day keeps its current badge until that day: storedretiredshows "Retiring" through itsexpiryDate, and storedactiveshows "Coming" until itsavailableDate. The alarm then has nothing to fire on, and the due-soon issue says how to store a re-read this way. - Only a row that says when it was read is held. A row stored after
its day stays as stored on every clock. AZ-800,
retiredonce 2026-09-30 had passed, staysretiredeven on the earliestDATA_AS_OFthe education index pre-renders with. A first version without the date showed it bookable there. -
AB-400 is stored
activeand PL-400retired, both withstatusReadDate2026-10-12. Microsoft's exam pages, the AB-400 study guide and both dates were unchanged that day, as the Azure catalogue's header quotes. -
Admin → Integrations → Credentials shows the rotation date you recorded (#1026). On 2026-10-10 and 2026-10-11 the owner recorded the rotation dates of the eight credentials with a rotation rule, twice, and the tab still read as though none had been saved. Every save had landed: the API's request log holds 24
PUT cms/credentialsacross those two days, all 200. What the tab never did was print the stored date: - The Record form, labelled "Rotated on", always opened on today's date, so reopening it to check looked like the save had been lost.
- The line under a row's age named only where the age came from ("recorded", "Keys tab"). Where a Key Vault write was newer than the recorded rotation, the age came from that write, and the recorded date appeared nowhere.
Now the form opens on the recorded date, and on today only when nothing
is recorded. The line under the age names the recorded date whenever one
is stored: recorded 2026-08-20, or Keys tab; recorded 2026-08-20 when
the Keys tab's write is newer. Tests for both are in
credentialsView.test.js and IntegrationsCredentials.test.jsx.
- COPILOT_REVIEW_APP_PRIVATE_KEY is recorded as stored (#1063). The
owner stored it in the Agents store on 2026-10-11, and
gh secret list --app agents lists it. The required-inputs row now reads
SET, and the Copilot code review runbook says how to confirm it. A manual
dispatch of the setup workflow cannot confirm it, because that run reads
Actions secrets rather than the Agents store, so it prints the "not
resolvable" notice either way. The first Copilot session after 2026-10-31
is the check.
- Lab host: the add-ons' egress cleanup works on an empty chain and on its
own rules (ADR 0035). The bootstrap after #1059 stopped at "Remove the
egress rules this role wrote on an earlier run". The inline pipeline
iptables -S DOCKER-USER | grep -F … | sed … | while read rule; do iptables $ruleran underpipefail, and on the host's first run the chain held none of the role's rules, sogrepexited 1 and that became the task's failure. Reproduced in Ubuntu 26.04 with iptables 1.8.11 (nf_tables), the host's own versions. The same reproduction showed the next failure waiting: with a rule present,iptables -Sprints the comment in quotes, the unquoted$rulesplit it, iptables answered "Bad argument `egress'", and the loop still printed "removed" and exited 0. Every later run would have deleted nothing while saying it had. It also deleted the role's reject before inserting it again, so the add-ons' subnet ran unfiltered for the length of the rebuild, or for good if the run stopped there (CodeRabbit on #1060). -
lab-host/ansible/roles/addons/files/hcw-addons-egress-clear, installed as/usr/local/libexec/hcw-addons-egress-clear:guard, in oneiptables-restoretransaction, puts a reject for the whole subnet at the top ofDOCKER-USER(commented… rebuilding) and deletes every rule whose comment is exactly the role's, by its full specification and never by position, so another writer's rule cannot be hit.- The role inserts its rules above that reject.
unguardlifts it after the last insert.
A run that stops in between leaves the reject, so the add-ons lose egress rather than keep it unfiltered. Its arguments are held to the shapes they should have before they reach the transaction. A missing chain fails with iptables' own message, instead of being hidden by
2>/dev/null. -tests/hcw-addons-egress-clear.test.shruns it as root against real iptables, in a scratch chain (17 checks): - an empty chain; - the role's rules among another comment, a comment containing the role's, and Docker's RETURN; - a guard run again after a stop; - a held reject for another subnet, which does not stand in for this one (guard checks its own withiptables -C); - the rebuild under the reject; - several held rejects; - a missing chain; - input that could break out of the transaction.CI runs it in the
ansible-lint (lab-host)job. -
Lab host: an add-on with no digest yet no longer stops the bootstrap (ADR 0035). The
migrationrow is enabled with an emptyimage_digest, which by design deploys nothing and only says so, "because the other roles must still converge". But theaddonsrole asked every enabled row for its vault keys, so on 2026-10-10 the run stopped at "Refuse to start an add-on whose vault key is not set" for a container it would never have started.portainer,vault, the privilege checks and the applied-commit record never ran. lab-host/ansible/roles/addons/tasks/main.yml: the keys are collected fromaddons_deployed(enabled, with a digest), the same set the test-key check and the container tasks use. A row without a digest gets the "not deployed" note, and its key is asked for once it has a digest.-
The playbook comment, the role's argument spec and
lab-host/README.mdsay the gate is on a row being deployed. -
The role-change alerts are a rule per resource type, which Azure accepts (PLAT-4, #964). The apply that created the other PLAT-4 rules refused
alert-rbac-write-appandalert-rbac-write-mgmtwithUnsupportedCondition: "Alert Rule with category 'Administrative' and subscription scope must have at least one additional filtering condition." azurerm sendsresource_typesandstatusesas any-of groups, and Azure counts only a single-value condition as that filter. infra/observability.tf: each rule became two, one perresource_type(a singleequals, the shape of Microsoft's Administrative sample), still Succeeded and Failed only:alert-rbac-assignment-app,alert-rbac-definition-app,alert-rbac-assignment-mgmtandalert-rbac-definition-mgmt. The role-definition rules never fire on an apply, sinceinfra/declares no role definition; they catch a custom role widened by hand.scripts/assert-expected-plan.mjsdeclares the four creates in place of the two.-
docs/runbooks/alerting-and-support.md: the rule table, the fire record, and a fire test for the role-definition rules (a throwaway custom role, named and recorded before it is created, then deleted by that name). ADR 0005's grants table names the four rules. -
The alert verifier checks every declared rule, weekly, and the runbook stops calling proven delivery unproven (PLAT-4, #964). The estate assessment found
verify-alert-state.ymldispatch-only, last run 2026-08-30, reading three hard-coded rules whileinfra/declared thirteen across two subscriptions. - The rule list is derived, not written down.
scripts/lib/alert-declarations.mjsreads every.tffile as one module, with a small HCL scanner (scripts/lib/hcl-blocks.mjs) that respects strings, heredocs and comments, and resolves each alert rule's Azure name, resource group, subscription (from the provider alias through the(sub-…)in its variable's description),countgate and action group. An alert type it cannot read back, a name it cannot resolve, or a rule whose provider and resource group sit in different subscriptions stops the check instead of dropping out of the inventory. scripts/verify-alert-state.mjsreads the live rules through ARM GETs with oneaz account get-access-token, in every resource group the declarations name, and fails naming each ruleMISSING,DISABLED,NOT WIREDtoag-plat-prod-cus-01, or inDRIFTonautoMitigate. It reads the action group too, and fails one that is disabled or has no enabled receiver, printing receiver names and never their addresses. A rule whose createassert-expected-plan.mjsdeclares is reported as waiting for an apply; a group the identity cannot read isNOT AUTHORIZED, neverMISSING. Gated rules are expected per aGATESrecord of the workspace's gate values, which the tests require for every gate a rule uses.- The workflow runs every Monday at 07:53 UTC and on dispatch, still as
github_reader. That identity holds no role in the Management subscription, so the capacity and lab rules and the action group reportUNREADABLEuntil the owner grants a read onrg-mgmt-plat-prod-cus: listed in every run and raised as a warning, their state called unknown, while the run passes or fails on what it can read. A weekly red run for a gap everyone knows is howvalidate-deployed.ymlcame to be ignored. A refusal anywhere else still fails, and once the workflow can read a recorded subscription the run fails asSTALE RECORDuntil the entry is deleted; the step'scoverageoutput readspartialmeanwhile. The report's verdict is always its last line. This change does not widen the identity. A rule live while itscountgate is recorded off isDRIFT. The workflow and runbook no longer callgithub_readera pure reader: it also holds the origin-window config write on the Function App. -
Delivery was proven on 2026-08-30: a CLI test notification reached
ops-emailand thenops-sms(T-709), and the cross-subscription hop had delivered on 2026-08-25 whenalert-app-exceptions-prod-cusfired. The runbook's Delivery is unproven section, ADR 0022's consequence, the cost analysis, the deployment runbook's §4 step 3 and the comments ininfra/observability.tfnow say so, with the CLI commands that re-prove it. The runbook's operator table gains the reachability, export and Telegram rules it omitted, andverify-alert-state.test.mjsfails when a declared rule has no row there. -
A missing function, an origin lock that is off, and a stranded AzureWebJobsStorage strip each fail a check now (PLAT-2, #962). The estate assessment found all three passing.
- Registration is checked by name.
deploy-functions.ymlasserted more than zero registered functions andmonitor-functions-registered.ymlat least one, so the 2026-08-21 incident (83 deployed, 80 registered, three timers down) passed both.functions/function-inventory.jsonlists all 248 functions by trigger, andfunction-inventory.test.jsfails, naming each name, when it and the registrations inindex.jsdisagree.scripts/check-registered-functions.mjscompares a live listing with it and names every function missing. The deploy runs it against its own commit, in a step of its own after the firewall windows close, polling for up to three minutes. The monitor runs it against the commit whose package is live, found from the last successfulDeploy to Azure Functionsstep, so a merged and undeployed function is not reported missing. It reads twice more, a minute apart, before believing a miss. It holdsactions: readto read that run history. Until a deploy carries the inventory, the monitor says the check is not armed and asserts only a non-zero count, as before. - The deploy's origin-lock step read Cloudflare. It curled the proxied
hostname, which Bot Fight Mode answers with a 403 whether or not the
Azure restriction is on.
scripts/assert-origin-lock.mjsnow reads the Function App's access restriction from the control plane and holds it toinfra/. Unmatched requests must beDeny. Every address in every Allow rule is read, so none can admit everyone beside a permitted one.cloudflare-*rules may list only the declaredcloudflare_ip_ranges, and each declared range must be admitted.ci-*windows may admit one address, and this run's window must be gone. It runs whenever the Azure sign-in succeeded, so a deploy that fails early still checks the standing lock. A deploy whilefunctions_origin_lock_enabledis false now goes red. The deploy identity's Website Contributor on the app already covers the read. - No Terraform ordering can keep a failed apply from stranding the strip.
Terraform skips only the dependents of a failed resource, so an error
elsewhere cannot stop the strip, and that was already true. An error on
the path itself can: the Function App's update, the settings read, the
strip, or a cancel or
-targetbetween them.infra/functionapp.tfnow says exactly that, andwebjobs-strip-ancestry.test.mjsfails if the pair gains an ancestor. The deploy now assertsRUNTIME_CONFIG_WRITER = azapi-stripas well as an absentAzureWebJobsStorage, as the monitor always has. The deployment runbook gives the owner's two PowerShell reads after any apply that did not finish green. It also corrects §5's claim that Terraform "stops scheduling new nodes" on an error. - A Docker data-root switch no longer leaves Coder unreachable, and the
Coder database volume has one owner. On the first bootstrap after LAB-5
(2026-10-08) the old root's bridges stayed in the kernel, down, on the
Coder networks' fixed subnets, and their routes came first. The host's own
traffic to Coder, Docker's port proxy on
127.0.0.1:7080included, went into an empty bridge, so Caddy answered 503 and the site said lab workspaces were unavailable, while Coder and PostgreSQL ran normally and talked to each other. Deleting the two stale bridges brought it back at once. - The docker role now runs
hcw-docker-stale-bridgeson every bootstrap, once the daemon is up. It deletes a bridge only when its network is recorded in another Docker data root on the host, the running daemon does not list it, and nothing is attached, so another program's bridge named like Docker's, or a network the daemon has just created, is never touched. A test runs it against stand-ins fordockerandipand a scratch data root. coder-postgres-dataisexternal: truein the Compose file, and the coder role creates it beforeup. Compose had warned on every run after the carry that the volume "was not created by Docker Compose". It used the carried data, all 76 MB of it, but nothing said who owned the volume. Now the role does, anddocker compose down -vcannot remove it.- The lab host's bootstrap no longer stops at
'ansible_managed' is undefined. Since ansible-core 2.19,ansible_managedexists only for the template module. Three copy tasks that LAB-5 added (#987) named it in theircontent: the systemd delegation drop-in and the tmpfiles entries for the sandbox and agent proxy sockets. The first run after the merge failed at the first of them, after the docker role had moved Docker to its remapped data root and before the coder role had recreated Coder there. Each now writes the literal# Ansible managed, the text the templates render, so a host that ran the one-off-e 'ansible_managed="Ansible managed"'sees no change on its next run.scripts/lab-host-ansible-managed.test.mjsfails on the variable in any task, handler or vars file; ansible-lint does not render task arguments, so nothing in CI had seen it. - The Health Hub keeps its results, has a pulse, and shows every status in one place, in one model (#1010, #1011). Until now the hub forgot every verdict on sign-out or in a new tab (live results lived in one tab's sessionStorage, the rest in React state), nothing checked anything unless someone pressed a button, and the status badge wrapped under the description on every probe card but "Publishing failures".
- One model, written once in
lib/status.js: Healthy, Degraded, Critical (it answered but said no: a missing setting, a refused key, a failing check), Offline (it could not be reached, or missed three heartbeats) and Unknown (never checked, or past its freshness window).misconfiguredandunavailableare read as Critical and Offline, so stored results and older callers keep working. Offline has its own dark badge with an unplugged icon. The freshness windows are 15 minutes for the pulse and the snapshot, a day for live and session checks, 90 minutes for the session token. The server keeps a copy for the pulse;statusParity.test.jsfails if the two disagree on any id, window, classification or verdict. - Every probe result is stored server-side, one
admin_configdocument per probe (GET/PUT cms/health/probe-results, viewer to read, the probe's own role to write, ETag-guarded with status transitions). The page reads it on load and every minute while visible, shows each card's age and who checked it, and shows a stale result as Unknown beside its last value. healthPulse, a new timer every five minutes, records what the server can check alone (Cosmos, runtime configuration, Key Vault references, storage, scheduled publishing, publishing failures, queue age, orphaned images, links, the forge, Telegram notices, lab agent heartbeats, the AI providers' and MCP servers' last recorded tests) and its own heartbeat. The pulse line above the tabs says when it last beat. While it is late the hub is Offline; until it first beats, Unknown. It is off untilHEALTH_PULSEis added to theenabled_timersworkspace variable.- Every Health card has the same header, body and footer, with the status top-right in a slot that cannot wrap or shrink, including the Overview and Alerts cards. Cards in a row are the same height.
digest.publishingOpshad three readers and no writer. The scheduled publisher now records every run there, so the Overview, the Telegram/digestand the Scheduled publishing probe see real runs, and three missed 15-minute runs read as Offline.mergeDigestis ETag-guarded, so timers merging the same day's digest in the same second no longer erase each other's blocks.- The admin dashboard's pipeline fits its card with six stages of one size, the Publish stage shows its count, and every number on the page opens what it counts.
- The pipeline row was
flexwith amin-w-38box per stage. At 1280 px the six boxes, five chevrons and gaps needed about 1072 px of a 926 px card:#admin-mainscrolled 97 px sideways (17 px at 1440), Live Pages ran past the card's edge, and five of the six descriptions were cut to a few letters. The stages are now an ordered list on an equal grid sized by the card, not the window: six columns from 56rem of card, three from 28rem, two below, every box the height of the tallest. The count sits beside the step number, where it cannot squeeze the label; descriptions wrap to two lines; the chevrons sit in the gaps and only when the six share a row. getAdminDashboardSnapshotreturnsreadyToPublish(approved, forge_ready or published, and not live), shown on the Publish stage and on the sidebar's Publish item. It is the same COUNTgetPublishSnapshotnow runs forreadyTotal, which counted every live page as staged: the ready window includes thepublishedstatus, and only the list dropped live items in JS. The publish fetch excludes them too, so its 150-row window is not spent on live pages.- The stat row is a shared
StatTile(components/admin/shared) in the page's own section card: one height, a note line held open on every tile, spans rather than paragraphs. Tiles went from 192 px, with 70 px of nothing under the label, to 124 px. Five columns from 48rem of card; not@2xl, whichindex.csssets to 1400 px. - A type tile's number is still the type's total across every stage, and
the section now says so. No admin list shows that set (the Review Queue
and the Frameworks and Coder Corner lists all open on needs-review and
have no all-statuses view), so the Blogs tile used to land on a list
that did not hold its number, with nothing to say so. Each type tile
now names
status=needs_reviewin its link and its note says how many are waiting there. The headline count and the live, editor and rejected counts in the header are links too. - One 24 px gap between sections (was 32), and the paragraphs that close
a block set
mb-0over thepdefault. On a phone the header's actions wrap below the headline instead of squeezing it onto four lines. The Decision Center below the stat row is the one from #1021, unchanged here. DashboardPage.test.jsxlooked for the pipeline withclosest('[data-slot="card"], div'); the Card renders nodata-slot, so it only ever found the nearest div. It now checks the ordered list of six.e2e/admin-authenticated.spec.jschecks the dashboard at 1280 px, 1440 px and on a Pixel 5: no sideways scroll, the row inside its card and centred in it, six boxes within 1 px of one size, no description cut, five even stat tiles. Run against the old row layout, all three fail.- "Needs a decision" on
/adminis now a Decision Center over every decision waiting, and an article sent to review is one of them (#1013, #1014). It used to show the newest few Review Queue items, and "newest" was the newest of an unorderedTOP 30. - One read.
POST getDecisionCenter(viewer) gathers the Review Queue, Editor work, frameworks and Coder Corner awaiting review, forge-ready and staged content, podcast transcripts, Listen & Learn chapters, newsletter issues, social posts and Forge Studio Queue entries that failed, open workflow alerts, unresolved Key Vault references, due or overdue reminders, and Ambassador deadlines inside two weeks.- Each source is one
TOP-bounded read ordered by_tsin Cosmos. - A source that fails is named in the answer and costs only its own items.
- Editor-only sources are left out of a viewer's answer and listed as such.
- Each source is one
- Six tabs. Needs a Decision, Frameworks, Queues, Pipelines,
Governance and Other Actions, each with its count, newest first. The tab
is kept in
?decisions=, so Back from an item returns to it. - Each row opens its item at its stage.
lib/itemLinks.jsis now the one place that maps a content stage to its page. New deep links ring the item where it waits:?transcript=on Recording Hub › Transcripts?issue=on the Newsletter and Drafts tabs (it worked on Published only)?alert=on Health › Alerts?reminder=on Platform › Reminders?platform=&exam=&chapter=on Listen & Learn › Review
in_reviewneeds a decision. Drafts' Send to In Review and the repository import write it. It was counted on the Editor badge, which the Editor list cannot show, and was missing from the Review Queue's default view and the headline count. It is now inneedsReview, the queue's default view, and the Frameworks and Coder Corner "Needs review" filter. The queue orders an article sent from Drafts by when it was sent, so it no longer sorts last for lack offetchedAt.- Recount now re-derives each document's counter marker as well as the
counters. The change feed computes every move from the marker, so a
recount that rewrote only the counters would have let an
in_reviewmarker from before this change take its document out of the wrong bucket on its next edit. Press Recount on/adminonce after the Functions deploy. Until then the Editor badge still counts articles in review. - The pull request's review closed four ways an item could still go
missing:
- The review queue leaves frameworks and Coder Corner out in the
query, on the canonical type, rather than after the read, so a run
of them can no longer fill its window. The two type sources match on
the same rule, so a
Frameworktyped in another case is in exactly one source instead of none. The queries were run against the Cosmos emulator. - Newsletter issues are read by their last write, so a keep or a reject of an old issue is in the window.
- The queue's other views (live, ready, in progress, rejected) read their window in their own sort field's order, with the rows that lack the field after, instead of the newest-written window.
- Recount writes the counters only under the ETag they had before its scan. A change-feed update that lands during a run makes it scan again, up to three times, and then say so, instead of writing older counts over newer ones.
- The review queue leaves frameworks and Coder Corner out in the
query, on the canonical type, rather than after the read, so a run
of them can no longer fill its window. The two type sources match on
the same rule, so a
- The element defaults in
index.csssit in@layer base, so a utility on a paragraph or heading now applies. Theprule (margin-bottom: 1rem; line-height: 1.7), theh1–h6rule (color: var(--dark-gray); font-weight: 600), thebodyrule and theheader, footerchrome were outside any layer, and unlayered CSS beats every rule in Tailwind's@layer utilitieswhatever its specificity: nomb-*,leading-*ortext-xsline height could change a<p>, and notext-*colour orfont-*weight a heading, typography'sproseincluded. Each keeps its place and its values, so an element that sets nothing renders as before. The provider-theme overrides (:is(.theme-*) header,footer,a) stay unlayered on purpose. - Measured on the built site before and after, at 1280 px in both themes:
the home page, an Azure blog post and
/education/labschange only where an element's own utilities now apply. Headings take theirfont-boldand written colour,text-smandtext-xsparagraphs their own line height (/education/labsis 108 px shorter), and the article body typography's spacing andprose-p:leading-[1.8]. axecolor-contrastover 84 routes in both themes: the same 48 violations before and after. - 46 headings on 16 public pages had depended on the old colour. The
dark-first pages (the AWS, Azure, GCP and FinOps architecture pages, the
podcast page and episode player, GitHub tools and workflows, FinOps
tools) set
text-whiteon headings that sit on light surfaces in light mode, which the forced colour had hidden: against the painted background they read 1.0 to 2.6:1 once it moved. They droptext-whiteand keep the heading colour they always rendered with. The mutedtext-slate-400eyebrows on the architecture and education pages gaintext-slate-600for light mode, and the home page's hover-panel eyebrowsdark:text-slate-400. ProviderStripand the Docker pages drop the!m-0,!leading-6and!mb-0the old rule had forced on them.- Replicate MCP and Hostinger MCP sign in with OAuth Connect instead of
sitting red on the AI Engine. On 2026-10-08 both cards read "Error":
Replicate's hosted server (
https://mcp.replicate.com/sse) answers theREPLICATE_API_KEYbearer with401 invalid_tokenbecause it accepts only tokens from its own sign-in, and Hostinger pointed athttp://localhost:8100, where nothing ever listened and whose key was never an app setting. Both servers publish the MCP authorization profile, and nothing here could follow it. functions/src/lib/ai/mcp-oauth.jsis that profile: protected-resource metadata (RFC 9728, the 401'sresource_metadatafirst), authorization- server metadata (RFC 8414, OpenID configuration as the fallback), dynamic client registration (RFC 7591) as a public client, PKCES256only,resource(RFC 8707), the code exchange and the refresh grant — every request throughguardedFetch, https on every hop (its newhttpsOnlyoption), and no redirect followed by a request carrying a secret. A server withoutS256or a registration endpoint is refused with a sentence that says which.- Three super_admin routes,
POST /api/cms/mcp/{serverId}/oauth/start,/api/cms/mcp/oauth/completeand/api/cms/mcp/{serverId}/oauth/disconnect. The pending sign-in lives write-only on the server's document (the state's SHA-256, the verifier, who started it, ten minutes) and is refused for another administrator, after expiry, and on replay. The vendors return the browser tohttps://hybridcloudworks.com/admin/ai-engine/oauth/callback, a new lazy admin page that completes the sign-in and lands back on the MCP Servers tab with the result. Connect and disconnect are audited. mcp.jsrefreshes an OAuth Connect server's token when it has under two minutes left, refreshes once and retries once on a 401, and never calls such a server without a connection. A refresh the vendor refuses turns the card amber, "Sign-in expired", with Connect — not red. TherefreshPlaudTokentimer also refreshes these servers when their token expires within 30 minutes; its name, flag and schedule are unchanged, so nothing needs re-arming. Plaud keeps its own refresh.- The seeds and their migration: Replicate keeps its SSE URL and loses
apiKeyEnvVar; Hostinger moves tohttps://mcp.hostinger.comover Streamable HTTP; both gainauthType: 'oauth'. The lab-host token's localhost binding and the URL rule's loopback-http exception served only the old Hostinger entry and are gone. A config write that changes a server's URL, transport, key or sign-in method clears its stale status and error, and one that moves a connected server's URL disconnects it. No config write can set the flow's own fields, and reads strip the client secret and pending sign-in as they strip the tokens. - The token is the super_admin's vendor account, and Hostinger's tools can
change VPS, DNS and domain settings, so calling a Connect server's tools,
switching it on or off and changing
authTypeneed super_admin; every other server keeps the editor gate. Found by the change's own security review, and fixed here: the config routes now refuse a body naming a nested path (a.b) on either collection. Cosmos patches those inside a field, so an editor could have pointedoauth.tokenEndpoint— and with it the refresh token — at their own host, andallowedTools.0had slipped past #995's super_admin rule since that change. The callback'siss(RFC 9207) is checked verbatim against the issuer the sign-in started with, and an SSE stream naming a message endpoint on another host does not get the token. - The pull request's review closed the rest of the ways round those
rules. A PUT is judged on the document it stores, so leaving out
enabledorurlis a change like naming it, and an omittedauthTypeis kept rather than dropped. A config write cannot set a Connect server's tokens, and deleting one needs super_admin, as Disconnect does. The metadataissuermust be the identifier it was fetched for (RFC 8414): Replicate's origin written with and without the root/is one URL, and any other spelling, path or tenant is refused. The final token write of a sign-in, a refreshed token and a refused refresh's disconnect are each written under the ETag of the document they were decided from, so a Disconnect, a URL move or another instance's newer token that lands meanwhile is never undone. Log lines name no server document. - The owner presses Connect on each card and signs in at the vendor: MCP servers — OAuth Connect.
- The Hybrid Lab's health signals say what is true, and the repository move leaves no trap behind (#1009). From the read-only review of 2026-10-08.
- The Coder status token's expiry check reads a 404 on its own record as
the missing
api_key:readscope, as it reads a 403: Coder v2.38 answers an unauthorized read as not found, so the check could never say what to do. The Integrations card now says to re-issue the token with that scope. - A graceful stop no longer sends a critical "Lab agent offline" on the
spot. The agent is still marked offline at once;
checkAgentHealthsays "offline" once it has been down five minutes, once per outage (offlineNotifiedAton the agent's document), and the first heartbeat back sends "Lab agent back online after N min", retried by the timer if it could not go. That morning's 04:30 reboot, back in 43 seconds, had sent a red alert and nothing after it. - An agent whose fresh heartbeat says
stoppingoroffline, or that the registry has deactivated, is not online. The goodbye heartbeat wrotelastSeenAt, so the public door stayed open for 90 seconds after a stop; astoppingheartbeat with jobs still draining was stored asbusy, and the agent's own 30-second heartbeat saididleduring the drain, so the door reopened until the finaloffline. Both now staystopping. checkAgentHealthwrites its offline mark with the ETag of the read that decided it, so a heartbeat landing between the two wins and no false outage message goes.- The public estate card's "Last heartbeat" was Arc's last status change, so a healthy host read as silent for days. It is now "Arc status since", beside a new "Job runner heartbeat" row, and policy checks that can never pass on the lab (the guest-configuration baseline, Defender's update and SQL assessments, the seal-key vault's private link and firewall) count as not applicable, each for a recorded reason, instead of as failures.
checkAgentHealthlogs marks, recoveries and messages at warn, whichhost.jsoningests, andvps-agentwrites its errors and warnings with the syslog priority prefixes<3>and<4>, so the lab host's data collection rule picks them up. What those lines say is content-free: counts and the error's class, never a document id or an error's text (a docker-runner error can quote a path from a learner's payload). The agent writes the job id and the detail on an info line beside each, which stays in the host's journal.- Every live reference to the repository under its former owner now
names
saulpatinojr: the lab host's clone URL, the agent's checkout, the Coder template's clone, and the runbooks'gh --repolines and raw download. The CMS draft import, which refuses redirects, fetches from the new owner with its draft ids unchanged.bootstrap.shrepoints an existing checkout's origin, and CI fails on the old address or owner/name in any tracked file outside the dated records. Coder's allowed GitHub organisation is unchanged, pending #1016. - The labs host pages say what is merged and what the host has applied,
and the runbook states the order after a merge:
bootstrap.sh, then the Coder template push at once. lab-pins-upstream.mjsescapes the package name in its apt index regex. The fix had been stranded since #899. Commit7c0e84fawas pushed tofeat/lab-supply-chain25 seconds after #899 merged, which re-created the branch and kept the commit out ofmain. Unescaped, the dot incontainerd.iomatches any character (containerdXiopassed); this is CodeQL alerts #386 and #390 (js/regex/missing-regexp-anchor). The commit was cherry-picked unchanged onto a branch frommain, with its test (a non-matchingcontainerdXiostanza now reads null). Rule recorded: a fix found after a merge goes on a new branch, never on the merged one.- Two frontend tests that failed under load now wait for what they
assert (#997). Each turned a run on
mainred on 2026-10-08 and passed on the next run of the same workflow. LabPanePage› full screen queried "Exit full screen" synchronously, though the pane reads full screen throughuseSyncExternalStore, so the relabelling render could land after the query. It now awaitsfindByRole.ForgeStudioPage› Send to review waits up to 5 s three times, but kept vitest's default 5 s limit for the whole test, so under load it was killed before it could use its own waits (Test timed out in 5000ms, CI onacbbd716). It now has a 20 s limit.- The Docker and lab drafts describe the lab images as published to Docker
Hub only.
blog-docker-01-building-images.md,blog-docker-03-docker-hub-oidc.mdandblog-lab-02-one-container.mdindocs/contentdescribed GHCR first, with a job that copied each image to Docker Hub by digest. They now describe thepublishjob's OIDC sign-in (900 seconds, after the smoke tests) and its directdocker push, the digest read back from Docker Hub,docker.io/hybridcloudworks/…names, and the subjectrepo:saulpatinojr@34853639/….gh attestation verifynamessaulpatinojr/HCW-HybridCloudWorks, its output re-measured on 2026-10-08. Part 3's copy-by-hand section went with the copy, and the Lab 03 draft clones fromsaulpatinojr/HCW-HybridCloudWorks. - undici stays on the major Node 24 bundles, and a test now proves the
guarded fetch works with the runtime's own fetch. Dependabot's #902
(undici 7.30.0 → 8.11.2) was green on every check. Measured on Node
24.21.0, the Function App's line, with its bundled undici 7.29.1: an
undici 8 Agent passed to the built-in
fetchthrowsfetch failed(UND_ERR_INVALID_ARG), while the undici 7 Agent reaches the server.guardedFetchdoes exactly that for every outbound request it pins, and every existing case injected a fake fetch or dispatcher, so nothing would have failed before production did.guarded-fetch.test.jsnow sends one request through the realpinnedDispatcherandglobalThis.fetchto a local server. CI runs it on Node 24: it passes with undici 7.30.0 and fails with 8.11.2, naming both versions..github/dependabot.ymlignores undici majors for/functionsuntil the engines move to a Node line that bundles undici 8. #902 was closed unmerged. - The version-floor tests judge their literal numbers against a frozen
floors file, so the weekly updater's pull requests can pass. The
updater's first pull request (#999: python 3.14.8, terraform 1.16.5,
2026-10-07) failed 29 tests, though no rule had changed.
update-version-floors.test.mjscompares fixture sources frozen at 2026-09-25 with what the file records, and it read that record from the livescripts/version-floors.json, so any moved floor read as the source going backwards. The rule tests inversion-floors.test.mjsstate floors as literals ("below the floor 1.16.2"), which go stale the same way. Both now readscripts/fixtures/version-floors-2026-09-25.json, a byte copy of the file on the day those numbers were written. The live file still decides every check of the repository's real pins, and its format is still checked againstserialise(). Proven by running both suites with #999's floors applied: 965 passed, where 29 had failed. - The lab images publish to Docker Hub only; GHCR is no longer
published. The first publish after the 2026-10-07 transfer failed at the
GHCR push with
permission_denied: The requested installation does not exist(run 37730460748). The repository's token can no longer write theHybridCloudWorksorganisation's package namespace, and the copy to Docker Hub, which read from GHCR, never ran. Owner decision 2026-10-08: one registry. The changes: publishsigns in through the Docker OIDC connection, with a 900-second token taken after the builds and smoke tests. It pushesdocker.io/hybridcloudworks/hcw-lab-runnerand…/hcw-labdirectly, and attests them there.- It no longer holds
packages: write.DOCKERHUB_ENABLEDnow switches publishing on or off as a whole. - The separate
publish-dockerhubcopy job is gone. - The two consumers now pin
docker.io/hybridcloudworks/…:IMAGES.hcwLabRunnerinvps-agent/lib/capabilities.jsand the Coder template's image. The digests pinned that day were already on Docker Hub byte for byte, so nothing was rebuilt. scripts/lib/lab-pin-bumps.mjsreads and pins the Docker Hub names.registryOf()now drops an explicitdocker.io/, which it had been passing to Docker Hub's API as part of the repository name.- The lab host's
lab_imagesrole matcheshybridcloudworks/hcw-lab*and still matches the formerghcr.io/hybridcloudworks/hcw-lab*, so images pulled from GHCR leave the host once no pin names them. - The weekly supply-chain scan reads the Docker Hub pins.
- The Docker images guide, Docker Desktop guide, lab image README, labs-host architecture page and Docker Hub runbook now describe one registry.
- The images already on GHCR stay there, public and no longer updated.
- Azure and Docker Hub OIDC trust the repository's new owner after the
2026-10-07 transfer to the personal account
saulpatinojr. The move changed the owner half of every OIDC subject GitHub presents, fromHybridCloudWorks@312844660tosaulpatinojr@34853639, while the repository ID1268997852stayed. From the next scheduled run everyazure/loginfailed with AADSTS700213, first visible as Monitor Functions Registered and Monitor Unresolved Secrets turning red. Deploys would have failed the same way.github_orgnow defaults tosaulpatinojr, andgithub_immutable_prefixininfra/oidc.tfcarries the new owner ID, so the apply replaces the six federated credentials on the three identities in place; the old-owner subjects are not kept, since nothing can present them.publish-lab-image.yml'sEXPECTED_RULE, the Docker Hub runbook's rule and its commands move with it. The runbook gains "After a repository transfer", because the connection's rule in Docker Home is edited by hand. Newscripts/check-oidc-owner.mjscompares the owner, owner ID and repository ID ininfra/oidc.tf, and the Docker rule, with theGITHUB_REPOSITORY,GITHUB_REPOSITORY_OWNER_IDandGITHUB_REPOSITORY_IDof the run itself. The existing cross-reference could not catch this, because every file moved out of date together. It has no dependencies, and the scripts row inci.ymlruns it on every pull request, outside the path filter. A transfer changes no file, so the pull request that has to catch one may be docs-only. The test suite imports the same functions and asserts that the step stays outside the filter. A future transfer fails a pull request rather than the first deploy. bootstrap-terraform-oidc.ps1takes the three deployment targets from the naming convention and asks nothing. On 2026-10-07 the picker listed all forty-six subscriptions the owner's sign-in could see, forty-three of them other organisations', as candidates for a Terraform grant, with the right three merely starred. When exactly onesub-app-*, onesub-plat-mgmt-*and onesub-plat-conn-*subscription is visible the script now prints them as[ok]and continues;-ChooseTargetsbrings the picker back, which then lists onlysub-*subscriptions unless-ShowAllSubscriptionsis given.Set-TerraformRbacCondition.ps1conditions every target subscription, not the one the CLI happens to point at.az role assignment list --allreads one subscription, so run from the bootstrap (whose context is the identity's home) it saw Management alone, printed "every row should show a condition" over one row, and on 2026-10-07 left connectivity's RBAC Administrator unconditioned. The script now takes-TargetSubscriptionIds, reads each with--subscriptionstated, defaults to the tenant'ssub-app-*,sub-plat-mgmt-*andsub-plat-conn-*subscriptions, and the bootstrap passes its own targets.bootstrap-terraform-oidc.ps1no longer replaces a federated credential whose subject differs without being told to. On 2026-10-07 thehcw-azureworkspace was found in HCP Terraform'sDefault Projectwhile the identity's credentials, the script default,infra/backend.tfand the deployment runbook all nameSite; every run failed at Azure sign-in withAADSTS700213naming the presented subject. The script's old behaviour on a mismatch was to delete and recreate the credential for whatever project it was given, which turns a wrong argument into a lockout. A mismatch now stops with both subjects printed and the two ways to agree: move the workspace back into the project the identity names (no Azure change), or re-run with-TfcProjectand the new-ReplaceFederatedCredentialsswitch.terraform-identity-grants.test.mjspins the default to the projectbackend.tfand the runbook name, so the three cannot drift.- The first digest pull request after #986 refused its own files.
scripts/open-lab-pin-pr.mjsreadgit status --porcelainthrough a helper that trims its output, so the first line lost its leading space and a fixed three-character slice ate the path's first letter:ab-host/coder/templates/hcw-lab/main.tfwas "outside the set" and the publish run's last job exited 2 on 2026-10-07.changedPathsnow parses the status code instead of slicing by width, follows a rename to its new name, and has a test for the trimmed first line. -
The admin portal re-checked access in a loop whenever the answer was not "authorized" (found by the signed-in browser journey, 2026-10-07).
onAuthStateChangedtold its subscribers about everyACQUIRE_TOKEN_SUCCESS, MSAL emits that for every silent token renewal, anduseAdminAuthanswers every callback by re-running the admin check, which renews a token. Anauthorizedanswer is cached, so that settled after a few rounds; a 401 or an unreachable API is not, so nothing did: against the stubbed identity the page made about a thousand token refreshes and a thousand status calls in six seconds, and in production it would have done the same to Entra and the Function App until the tab closed. A renewal for the account already signed in is no longer reported; a sign-in or a different account still is.entraAuth.test.jspins both, and the browser spec bounds the status calls on both 401 paths. -
The admin dashboard scrolled sideways on a phone (AP-F1). The Explore grid had an implicit column, sized to its content, and each item's one-line truncated description made that the whole sentence: on a Pixel 5 the cards were 675 px wide in a 393 px column. The grid now declares one
minmax(0, 1fr)column belowsm, and the browser spec checks that no checked admin route scrolls sideways. -
The agent health timer threw on every run since the 17:17Z deploy, and that was the app-exceptions alert (2026-10-06).
schedulers.jscalledcreateNotifierforcheckAgentHealth(#894) without importing it, so the handler failed with aReferenceErrorevery five minutes — three runs and six exception rows a quarter hour, one over the rule's threshold of five — and the agent-offline message could never have come from the timer. ThesendReminderstimer (#910) had the same hole. The import is now static, and the Functions package gainseslintas a correctness gate (no-undef, unused variables, duplicate keys, unreachable code), run by thefunctions (azure)CI row; against the previousschedulers.jsit reports both sites. The Telegram 403s the heartbeat path logged at 22:21Z and 22:23Z are Telegram refusing the bot or chat, not the code; the owner's direct test names which. -
Lab alert rules match the host by
_ResourceId, not byComputer(LAB-2 follow-up, 2026-10-06).alert-lab-heartbeat-prod-cusfired at its first evaluation after the apply, with the host up: for an Arc machine the Heartbeat, Perf and Syslog tables carry the OS hostname inComputerand the Arc resource name only in_ResourceId, soComputer == "arcs-lab-hybrid-prod-cus-01"matched nothing. The three rules now share one predicate on the lower-cased_ResourceIdsuffix. The heartbeat change is declared for the plan check; the disk and unit-failure queries are multi-line and the plan shows them beside it. Owner step: confirm thehcw-azurerun (3 to change); the heartbeat alert then resolves on its next evaluation. -
Arc lockdown tasks read each setting with
config get(LAB-6 follow-up, 2026-10-06). The first version parsedazcmagent config list --jsonas a list of name/value objects, which it is not, and the owner's firstbootstrap.shre-run stopped in thearcrole before the coder, labs_agent and later roles ran, so #900's proxy and unit changes never reached the host. The two settings are now read one at a time and compared as plain text. Owner step: re-runbootstrap.sh; success isfailed=0, thencoder-docker-proxyindocker ps. -
Lab host: the Coder server no longer holds the Docker socket, the agent's install and unit are tightened, the Arc agent is locked down, and ADR 0032's false detection claim is corrected (estate review 2026-10-06, findings LAB-5, LAB-6 and LAB-1's record; owner decision that day). The Coder server container mounted the daemon's socket and joined the docker group, so a vulnerability in Coder — reachable through Caddy by every member of the GitHub organisation — was root on the host, holding every other lab credential with it. A
coder-docker-proxyCompose service (tecnativa/docker-socket-proxy 0.3.0, pinned by index digest) now mounts the socket read-only and answers only the API sections the provisioner's Docker provider uses: containers, images, networks, volumes, the lifecycle verbs and the read-only daemon endpoints; exec, build, commit, swarm, system, plugins and daemon auth are refused. The server reaches it overDOCKER_HOSTon a control network the two alone share (PostgreSQL stays on thecodernetwork only, #900 review), and the template's provider names the same address; the server has no socket and no docker group, the role no longer reads the group id, andtemplate.test.mjsasserts all of it (three services, the read-only mount on the proxy alone, every proxy policy key, the networks, no port). Stated plainly, as the #900 review asked: this narrows a Coder compromise and does not contain it. The proxy reads paths, not bodies, so a compromised server can still create a privileged container or bind the host's/and start it; containing that needs a body-aware authorisation layer or a separate or rootless daemon for Coder, which ADR 0032 keeps as the revisit trigger with that shape written down. The labs agent'snpm ciruns--ignore-scripts(no dependency needs an install hook) and its unit gains address-family and system-call filters plus kernel, cgroup, setuid and realtime protections (MemoryDenyWriteExecute deliberately not: V8 needs it). Thearcrole setsextensions.allowlistto the Azure Monitor Agent and turns Arc incoming connections off, read-first so the play reports changed truthfully: any Contributor on the resource group could otherwise run a command as root through the control plane. ADR 0032's socket bullet carries the amendment, its validation list names the proxy, and its Cloudflare bullet no longer claims the plan check and drift monitor would show a production DNS edit — neither reads Cloudflare; the only record is Cloudflare's audit log, and the interim steps are the token's client-IP filter and expiry and an audit-log notification. Owner steps: re-runbootstrap.sh;docker psthen showscoder-docker-proxyand the coder template's workspaces still start (the proxy is the only path the provisioner has);sudo azcmagent config listshows the allowlist and incoming connections off. Rollback is a revert and a re-run. -
The lab host's pins and the lab images are checked against upstream every week, and a pin that falls behind or an image with a fixable HIGH or CRITICAL vulnerability opens an issue (estate review 2026-10-06, finding LAB-3; owner decision that day). Everything on the host is pinned and held — Docker Engine and containerd (which carries runc), Caddy and its Cloudflare module, Coder, PostgreSQL, Portainer, Vault, node_exporter, Node.js — and nothing noticed when a pin fell behind; Dependabot cannot read Ansible group_vars or a digest in a JS module, and the published lab images were never scanned after the build that attested them. New
scripts/lab-pins-upstream.mjsreads the ten pins fromlab-host/ansible/group_vars/all.yml, asks each publisher for its newest stable version (the Docker apt index, GitHub releases and tags, the HashiCorp index, Docker Hub, nodejs.org) and exits 0 current, 1 behind, 2 when a source could not be read, which is "not evaluated", not "fine". New.github/workflows/lab-supply-chain.ymlruns it every Tuesday, scans the two published image digests the host actually runs (read fromvps-agent/lib/capabilities.jsand the Coder template, never a tag) with the pinned Trivy for HIGH and CRITICAL vulnerabilities with a fix, and opens one issue, or comments on the open one, when either says something is due; a clean week is a green run. It bumps nothing: every bump needs a checksum or digest read from the publisher and reviewed beside the version, andlab-host/README.md"Bumping a pin" is the procedure. Dependabot gains adockerentry forlab-image/, the one thing it can watch there (the base image digest on theFROMlines). The first local run found Docker Engine, Caddy and Coder behind upstream, which is the signal that was missing. Owner step: none to arm it; the first run is Tuesday, or dispatch it from the Actions tab, and work the issue it opens. -
The browser suites run on every pull request, and the admin spec can fail (estate review 2026-10-06, finding QA-1; owner decision that day). No workflow ran Playwright: the hydration (6 tests), console-error (17 routes) and smoke specs executed only when someone remembered, and
e2e/contentforge.spec.jsheld sixteen tests whose every assertion short-circuited onpage.url().includes('admin'), so a blank shell or a removed auth guard passed.ci.ymlgains ane2ejob, deliberately not a required check for two weeks: it installs Chromium, builds once, serves the build withvite preview, and runs the hydration spec and the rewritten admin spec on desktop Chrome and a Pixel 5 (a newmobile-chromeproject; CI retries drop from two to one). Its first run (#899) ran console-errors and smoke too and showed why they cannot run here: a pull-request build has no API origin, so every public page logs "Public data fetch failed" and 32 of 70 cases failed on the environment; those two specs belong to the post-deploy probe of the deployed site (PLAT-5), and the job was scoped the same day. The admin spec now pins what a visitor who has not signed in gets on four admin routes: the sign-in card, no navigation, no menu button, no data; that the card is keyboard reachable and the route titled; that a 375 px viewport never scrolls sideways; and that the public home page never loads the identity library. An authenticated journey needs a stubbed identity and is the next step. The report and traces upload on failure. -
Admin Portal on a phone: an off-canvas menu with every label readable, a document title on every admin route, and the live counts in the link names (estate review 2026-10-06, findings AP-F1, AP-F2, AP-F3; owner decision that day). The admin shell rendered a 64 px icon rail that could not be dismissed, so a 375 px phone had about 263 px for the page and 71 px with the rail expanded, and the rail's labels were
titletooltips, which touch never shows. No admin page set a document title, so the tab, the history entries and the route announcer carried whatever the public site set last (WCAG 2.4.2). The badge counts werearia-labels on spans with no role, which screen readers do not reliably announce. Now: belowmdthe rail is not rendered; a slim top bar carries a menu button that opens a Radix dialog drawer (focus trapped, Escape closes it) with every group and label visible, and any link in it closes it. Frommdup the rail is as it was.PageHeadersetsdocument.titleto the page's title, andAdminLayoutsets a registry fallback on every route, so the announcer reads the new page's name. A link with a count is named "Review Queue, 3 waiting" and the badge isaria-hidden. The content column ismax-w-5xlwithp-4on phones (max-w-2xlthrottled the dense pages on a laptop). The two sub-12 px faded labels in the shell and the page eyebrow are 12 px at the full muted token. Tests: the link name carries the count, the title follows the route, the rail is hidden belowmd, the drawer opens labelled and closes on navigation. -
The lab host now pages the owner: heartbeat absence, root disk past 85%, a watched unit failing, and the labs agent going offline (estate review 2026-10-06, finding LAB-2; owner decision that day). Until now the Arc data collection rule shipped heartbeat and auth syslog only, node_exporter listened on loopback for a scrape that never came,
checkAgentHealthmarked an agent offline in Cosmos and told nobody, and the backup timer failed silently — the public status card said "unreachable" before the owner knew.infra/lab-hybrid.tfadds four host counters (CPU, memory, root disk used and free, 60 s) and thedaemon/syslog/kern/cron/userfacilities at Warning and above todcr-lab-hybrid-prod-cus(kilobytes a day against the cap), and three scheduled-query rules inrg-mgmt-plat-prod-cuson the existing action group:alert-lab-heartbeat(no Heartbeat row in 30 min, stateful, severity 1),alert-lab-disk(root past 85%, hourly) andalert-lab-unit-failed(ahcw-unit-failedsyslog line in the last hour). The lab-hosthardeningrole installshcw-unit-failed@.service, a template unit that logs one line atdaemon.errnaming the unit that failed, and the Coder backup, labs agent, Caddy and Vault units name it inOnFailure=. ThecheckAgentHealthtimer takes the shared Telegram notifier and sends one message (per the notifier's 15-minute cooldown) when it marks an agent offline, naming the agent, host and last heartbeat; the mark never depends on the message.scripts/assert-expected-plan.mjsdeclares the three creates; the DCR change is an in-place update the plan shows beside them. The alerting runbook's table carries the three rules; the lab architecture page's control-plane row is nowlive. Owner steps: confirm thehcw-azurerun (3 to add, 1 to change: the DCR), re-runbootstrap.shon the host so the notifier and the fourOnFailure=lines land, and expect the Telegram message within five minutes of stoppinghcw-labs-agentonce the Functions deploy carries this. The verifier workflow reads the web resource group only, so these three are listed byaz monitor scheduled-query list -g rg-mgmt-plat-prod-cusinstead (PLAT-4). -
Security: the Terraform run identity's role-assignment right is constrained, so a run can assign what
infra/needs and never make itself Owner or read the vault (estate review 2026-10-06, finding SEC-1; owner decision that day).scripts/bootstrap-terraform-oidc.ps1grantsid-plat-terraform-prod-cus-01Contributor + Role Based Access Control Administrator per subscription, and its comment said RBAC Administrator "cannot grant Owner or User Access Administrator". It can: without a condition the role writes any assignment, so a compromised workspace, a leakedTFC_TOKEN, or a malicious provider executed during a plan could have assigned itself Owner, or Key Vault Secrets Officer onkv-site-prod-cus-01, and read every production credential. Newscripts/Set-TerraformRbacCondition.ps1applies Microsoft's constrained delegation: an ABAC condition on every RBAC Administrator assignment the identity holds, built from role names resolved live, that refuses writes of Owner, User Access Administrator, RBAC Administrator, Key Vault Administrator, Key Vault Data Access Administrator, Key Vault Secrets Officer, Key Vault Crypto Officer and Key Vault Certificates Officer — except Secrets Officer to a USER principal, which is theadmin_object_idsseeding window — and refuses deletes of Owner, User Access Administrator and RBAC Administrator. Everythinginfra/assigns today passes. Idempotent, honours-WhatIf, re-creates an unconditioned assignment at the same scope. The bootstrap script calls it at the end of its role step and its comment now says what the role does. Owner step: run the script once (section 0 ofdocs/runbooks/deployment-runbook.md); success is oneconditionedline per subscription and every rowcondition presentin the listing that follows. -
One recovery objective pair everywhere, the as-built zone posture written down, a regional-loss procedure, and the first restore drill approved (estate review 2026-10-06, finding PLAT-1; owner decision that day). ADR 0011 said RTO four hours / RPO one hour with mandatory quarterly drills and a zone-aware, ZRS posture; ADR 0028 and
TODO.mdsaid RTO 8 hours / RPO 24 hours and that no drill was wanted; the architecture page repeated the July numbers; the restore runbook's header still said "not yet armed" a month after the exporter's first run. ADR 0011 now carries an amendment: the 2026-08-30 pair (8 h / 24 h) supersedes the July targets; Cosmos, the Function App and storage are single-zone with geo-redundant storage, each declined on cost; the 2026-09-09 no-drill decision is recorded and superseded by the 2026-10-06 approval to run the drill once, then quarterly. The architecture page's reliability model and the Well-Architected rows say the same. The restore runbook's header reflects the exporter running since 2026-09-09 and the drill being due, and gains "A regional loss": what survives (RA-GRS exports, code, state, the owner's secret copies), what does not, and the order a restore into a second region would run in, stated as untested and outside the 8-hour objective.TODO.md's accepted-risk row records the approval and deletes itself when the Drills table has a row. Owner step: run the drill (docs/runbooks/cosmos-restore.md, "The drill"); success is one dated row in its Drills table with measured RPO and RTO. -
Security: the deploy identity trusts the production environment only; its ref-form federated credentials and its unused Cosmos data-plane grants are gone (estate review 2026-10-06, finding SEC-2; owner decision that day).
id-site-github-deploy-prod-cus-01carried four federated credentials: the production-environment pair every deploy actually presents, and aref:refs/heads/mainpair that no workflow has presented since the healer went in #816 and the manifest moved to the reader identity in T-728. The ref pair was the way around the production gate: any job onmainholdingid-token: write(ten workflows do, for Qlty, Pages and the manifest) could have signed in as the deploy identity with no reviewer and deployed code that runs as the Function App's own identity.infra/oidc.tfdeletesgithub_branchandgithub_branch_immutable, and with them the two container-scoped Cosmos Data Contributor assignments oncontentandblogsthat outlived their--inspectreader and contradicted ADR 0025's "CI holds no Cosmos data-plane role" (the ADR carries the amendment).scripts/oidc-subjects.test.mjsnow asserts the deploy identity trusts environment-form subjects only, so the pair cannot come back unnoticed;scripts/assert-expected-plan.mjsdeclares the four destroys for the plan check. The reader identity keeps its ref pair: its workflows name no environment and it holds read roles. Owner step: confirm thehcw-azurerun — expected0 to add, 0 to change, 4 to destroy— and afterwards the federated-credential list shows the two production-environment subjects and nothing else. - Security: a shared integration key may only be sent to its vendor's
host, moving where a credential is sent needs
super_admin, and every AI provider or MCP server write is audited (estate review 2026-10-06, finding AP-B1). The key-name allowlist from ADR 0033 stopped an editor naming the Cosmos connection string, but it still let the three keys the seeded servers use be routed anywhere: a server saved at a host the editor controls withapiKeyEnvVar: "VPS_API_TOKEN"received the lab-host token as a bearer on the next Sync, in two requests.ai/mcp-policy.jsnow carriesINTEGRATION_KEY_HOSTS(Firecrawl, Replicate, and loopback for the seeded Hostinger entry) andvalidateMcpKeyBinding, which the config-collection writes check against the stored half of the pair (a URL move on a server holding a shared key, or a shared key placed on a server at the wrong host, is refused either way),syncMcpToolsandmcpProxycheck before any call, andresolveMcpAuthHeadersrefuses the bearer at an unbound host or with no URL, so a document that slipped past save-time still sends nothing. A write that moves credential routing — a new MCP server, a URL change, a key-name change — needssuper_admin, the role that seeds the keys; flippingenabled, renaming, reordering or storing an OAuth token stays ateditor. Everyai_providersandmcp_serversPUT, PATCH and DELETE now writes anai_config_updatedrow toadmin_audit_logswith the actor, the field names, and the URL and key NAME before and after (never a token value); the row is best effort, so a failed audit never reports a landed save as refused.MCP_*settings bind to no host: they are per-server secrets the owner creates for one server. Tests: the binding for every shared key and the MCP_* pass-through, no bearer at an unbound host, Sync and proxy refusing a rogue server without calling it, PUT and PATCH refusals in both directions, thesuper_admingate on moves versus the editor toggle, and the audit row's shape with no secret in it. -
Security: the article scraper and the supporting-document fetch now go through one SSRF guard (estate review 2026-10-06, finding AP-B2).
generateArticleDraftand theforge-from-urljob fetched whatever URL the editor named with a barefetchthat followed redirects, and the page (or 18 KB of a "KB document") came back into the draft or the source document. From the integration subnet that is a request-forgery primitive:http://10.0.0.5/, the metadata address, or a public host that redirects onto either. The guard the image fetcher has carried since T-734 is lifted intofunctions/src/lib/http/guarded-fetch.js— http(s) only,localhostrefused by name, the host resolved to IPv4 and refused when loopback, link-local or RFC 1918, redirects followed by hand with every hop re-checked, a deadline per hop — andcontent/scrape.js(the page itself) andcontent/draft-from-url.js(eachdocumentUrlsentry) fetch through it. A refused URL ends the scrape rather than being handed to the reader or headless fallback, which would only fetch the same forbidden target from their side.triggers/fetch-image.jsre-exportsisPrivateIpandvalidateFetchUrlfrom the new module, so its importers and tests keep their names. Copilot's review of #889 tightened the guard four ways, all taken: Azure's platform address168.63.129.16(WireServer, plain HTTP), carrier-grade NAT and the multicast/reserved ranges are refused with the rest; the connection is pinned to the address the guard validated, through an undiciAgentwhose lookup answers only that address (undici becomes a declared dependency; it was already in the tree under cheerio), so a rebinding host cannot pass the check with one record and connect with another, andfetch-image.jspins the same way; an IPv6 literal, and a name the IPv4 lookup cannot resolve, are refusals rather than fall-through to the reader; and the deadline now covers the body, which is read under it and under a byte cap inside the guard ({ response, buffer, text }comes back, not a live Response), so a document origin that sends headers and then stalls is cut off. Tests: the guard's own suite (every refused range including the Azure address, IPv6 literals, unresolvable names, pinned lookup in both callback shapes, a rebinding host, relative and private-bound redirects with re-pinning, redirect limit, streamed and declared body caps, a hung socket and a stalled body), scraper cases proving no fetch and no fallback on a refused target, and a drafter case proving nodocumentUrlsbyte is read from a private, link-local or localhost address or across a redirect onto one. -
Cloudflare DNS records:
ignore_changes = [include_shadow_metadata]on everycloudflare_dns_recordininfra/andinfra-lab/(owner decision 2026-10-06: stay on provider 5.27.0). Provider 5.26.0 surfaced theinclude_shadow_metadatarequest parameter as a record attribute with a static default, so every pre-existing record plannednull → false, the forced write came back with a newmodified_on, and thehcw-azureapply after #879 failed with "Provider produced inconsistent result after apply" ondocs_pagesandazure_functions(upstream #7387, #7396). Ignoring the attribute removes the spurious update; the records were written with identical content and both hostnames answered 200 throughout. -
Ambassador sub-programs: a cleared parent stays cleared, a program with children takes no parent, and a malformed chain shows rather than hides (#881 review). The seed backfill treated an explicit
parentProgramId: nullas missing and restored the seeded MCT parent on the next read, so "Additional to → none" could not stick; it now fills the field only when it is absent.checkParentProgramalso refuses a parent on a program that already has children (one level only), and the frontend'sprogramsInPlaylists a program whose parent is itself parented at top level, so a chain or cycle two overlapping edits could leave behind is visible and repairable on Settings rather than hidden. -
CodeQL alert 383 (note).
platform-settings.test.jsstill imported three names from the Listen & Learn speech settings that ADR 0034 slice 5 (#867) removed from that module or left unused; the import block is gone. - The weekly certification re-verify could not republish the snapshot
(#868).
schedulers.jshandedcreateCertReverifyasnapshots.publishSnapshotsthatcreateSnapshotPublishHandlersnever returned, so the Sunday run threw "is not a function" after marking a certificate expired, revoked or renewed, and the public snapshot stayed stale. The handler object now carriespublishSnapshots; the timers test pins the name against the real factory. -
06-seed-secret.ps1no longer reports a refused write as done. On 2026-10-05 it printedANTHROPIC-API-KEY setover aForbiddenByRbacfromaz(the data-plane role grant had been skipped), then crashed on the read-back with a null-argument error, and the operator read a seed that never happened as complete.azreports a refused call on stderr and exits non-zero without throwing, so both the write and the read-back now check$LASTEXITCODE, stop the run, and name the missing role as the likely cause; the firewall window still closes in thefinallyblock. -
GPT-5 models refused every OpenAI and Foundry call over
temperature(#849). The owner's first Test of the Foundry card, after the apply and deploy, reached the account and was authorised, then failed with400 Unsupported value: 'temperature' does not support 0.2 with this model. Only the default (1) value is supported.The OpenAI-compatible request body senttemperature: 0.2to every provider; the GPT-5 family accepts only the default, so the OpenAI provider, whose defaults moved to gpt-5-mini and gpt-5-nano, has been failing the same way. The table row now decides: NVIDIA's catalogue models keep 0.2, the OpenAI and Foundry rows send none.foundry-provider.test.jsandnvidia-provider.test.jspin both sides; the two setup guides say so. -
NVIDIA's Test timed out with a working key; every NVIDIA default is now GLM-5.3 (#701). The portal's Test sent
z-ai/glm-5.3-flashand gave up at 45 s, three times on 2026-10-04, after a freshly rotated key was seeded. A direct call from a workstation with a one-line prompt measuredz-ai/glm-5.3at 2.6 s,z-ai/glm-5.3-flashat 70 s anddeepseek-ai/deepseek-v4.1-flashpast 120 s, so on the trial tier the "flash" models are the slow ones. Drafts, analysis, multimodal and general calls all default to GLM-5.3 (functions/src/lib/ai/router.js), and the AI Services card offers and describes only that model. The other two keep their zero-cost rows and can return through aCONTENTFORGE_NVIDIA_*_MODELoverride. The stored card would not have followed: the page wrote it on 2026-09-25 pinned toz-ai/glm-5.3-flash, and the router obeys a pin for every purpose.providerModelPatches(frontend/src/lib/aiEngine/seed.js) now keeps each stored provider's model list equal to the seed on admin page load, and returns a pin the list no longer offers to the seed's own value, Auto for NVIDIA. A pin still on the list is left alone. -
Every provider the site routes can be chosen on the review board, Docker, VMware and Ansible included. #776 added
/docker/blog/<slug>, and VMware and Ansible have had blog routes longer. But the CMS knew six providers:normalizeProviderNameturnedDocker,VMwareandAnsibleinto''. The review board offered Azure, AWS, GCP, GitHub, Terraform and FinOps only, and "Send to Publish Queue" stays disabled until a provider is set, so an article for any of the other three could go nowhere. normalizeProviderNameis now derived fromPROVIDER_ALIASESinpublic-reads.js, the server's provider registry, whichpublic-section-counts.test.jsalready holds toVALID_PROVIDERS. The stored value is the route key with its first letter capitalised, asAwsandFinopsalways were:Docker,Vmware,Ansible. Every spelling the registry lists (Google Cloud,GitHub,VMware) still normalises.publish.test.jspublishes a Docker, a VMware and an Ansible post end to end, to/docker/blog/<slug>,/vmware/blog/<slug>and/ansible/blog/<slug>.PROVIDER_OPTIONSinfrontend/src/config/admin.jsis now built fromCANONICAL_PROVIDERSinlib/providers.js, the frontend registry, rather than kept as a copy. It feeds the review board's picker and the Publish-Ready Builder's provider and landing-zone dropdowns. The editor's provider filter, which had Docker but not VMware or Ansible, now reads the same list. The submission forms keep their own lists on purpose: each offers only what its page can file under.admin.test.jsandBlogReviewBoard.test.jsxfail if a provider is registered but missing from the board, anddocker-cms.test.jsholds the frontend registry tonormalizeProviderName.inferProviderFromTagsknows every provider too. It returnsDockeronly when no other provider is tagged: Docker on Azure is Azure, the same rule asdockercoming last inproviders.js. The server's URL inference and the recording drafter know Docker too, also last.- The public submissions API accepted no Docker submission, though the blog
and Coder Corner forms have offered it since #775. Worse, its framework
and Coder Corner lists had been swapped since the API was written, so it
refused every submission from either form. Each list is now its page's
list, and
submissions.test.jsreads the pages to keep them that way.ci.ymlruns the functions tests when those pages,admin.jsorlib/providers.jschange. -
AZ-800, AZ-801 and Google's PAA re-read on the day their dates passed (#770). From 2026-10-01 the catalogue alarm failed on every branch for three rows dated 2026-09-30. Each vendor page was re-read that day. Microsoft retired both exams as announced: the AZ-800 and AZ-801 pages now say "This exam was retired on September 30, 2026", and AZ-802 is open to schedule with no retirement date. Both rows are
retiredwithretiredDate2026-09-30 and still point at AZ-802. Google's Professional Agentic Architect page and its certification index are unchanged: the banner still says the beta "is open until September 30!", with no GA date, no new window and no withdrawal. PAA is therefore stored asupcomingwith no date, and its description says the beta has closed and GA is not yet dated. The tests that pinned the old statuses now pin what the vendors did, including the Azure page and detail-page tests, which expected AZ-800 to read as expiring. -
A new budget takes the month it is created in (#820). Azure refuses a monthly budget that starts before the current month, and
budget_start_datedefaulted to2026-08-01. From September, any apply that created a budget would have failed at the end of the graph, for a reason unrelated to the change: a fresh subscription, a rebuild or a forced replacement. The start date is now the first of the month the plan runs in (fromplantimestamp()), unless the variable pins one. Both budgets ignore the start date once they exist, so the two live budgets plan no change. cp_sortDateis written by the apply that could wipe it, not by a six-hourly healer (#816, first of two).azurerm_cosmosdb_sql_containercannot express computed properties, so an apply that updatedcontentorblogsdroppedcp_sortDate. That broke the public list's ordering untilheal-computed-properties.ymlput it back, up to six hours later.infra/cosmos.tfaddsazapi_update_resource.cosmos_computed_properties, one per container that carries a computed property. It is re-run byreplace_triggered_bywhenever its container changes, and a postcondition fails the plan or apply if the container does not carry the definition.- The definition moves to
scripts/lib/cosmos-sort-date.mjs. The generatedinfra/cosmos-containers.jsonnow holds the real query instead of a sentence. scripts/assert-expected-plan.mjslists these as conditional replacements, and learns to match a declared create by exact address.- The healer keeps running until this is seen to hold. #816's second pull
request removes it, its role assignments and its
--applypath.
The same change restores alert-func-http5xx to the 15-minute window its
description states: #250 had given it the availability probe's 30 minutes,
and with them the probe's reasoning.
- The inputs and secrets docs describe the estate as it is (#819). Most of
these pages were last true in August. Each change below was checked against
the code or the live estate on 2026-09-29.
- required-inputs.md, §4.2 and §4.3. The repository holds two
secrets, TFC_TOKEN and MANIFEST_APP_PRIVATE_KEY, not "one", and both
are now listed with their workflows, alongside MANIFEST_APP_ID.
READER_CLIENT_ID (set 2026-09-06, four workflows) and
VITE_TURNSTILE_SITE_KEY (set 2026-09-28) are no longer shown as unset.
FUNCTIONS_URL feeds the frontend build's VITE_AZURE_FUNCTIONS_URL,
which the doc called retired. STORAGE_ACCOUNT and
STORAGE_RESOURCE_GROUP are marked as read by nothing.
- required-inputs.md, §4.7 and §4.10. The lab rows read SET from
measurement: the Coder references resolve, the Arc fact is present, and
the lab agent is registered (its claims and heartbeats answer 200). The
outputs count is 24, which adds copilot_review_client_id.
- variables-and-secrets.md. The August placement errors are marked
resolved or moot. TF_API_TOKEN becomes TFC_TOKEN. The page no longer
cites the deleted key-vault.js, getSecret(), openai-client.js or
migrate-data.yml. AZURE_OPENAI_ENDPOINT is deliberately absent, and
KEY_VAULT_URI is back for the write-only API Keys path.
- Runbooks. deployment-runbook.md no longer describes the deleted
deploy-infra.yml, and points at §4.1, infra/budget.tf and
infra/functionapp.tf. alerting-and-support.md gives the latency alert
as P95, and the 5xx alert's 30-minute window as deployed. That window is
itself a bug, carried to #816. cosmos-restore.md says 61 of 74
containers, and lists tool_price_history among those not restored.
- Elsewhere. iac-repository-standard.md drops the removed agent and
issue template. routeFactory.ts drops a pointer to a guide that does
not exist. The runner-image rollout in version-floors.json is future
tense again. The admin NotebookLM entry records that the consumer product
still has no API, while Gemini Notebook Enterprise has a v1alpha one.
- The API Keys page no longer states a provider order the code contradicts
(#815). Its help text called Anthropic "second" and OpenAI "third", but
DEFAULT_PROVIDER_ORDER tries OpenAI before Anthropic. NVIDIA's text still
said it wrote "first", though it has been the backup since #812. The order
is the AI Engine page's to change, so the four provider entries now point
there instead of naming a position, and a test in
secret-catalog.test.js refuses any ordinal in them. The same pass fixed
module headers that described closed gaps:
- jobs.js: the stale-queued gap that platformJobSweeper closed;
- key-verdict.js, secrets-health.js and the catalogue: three
key-verdict reporters, not two, and four router providers, not three;
- ai-config.js: "three answers", not two;
- platform-health.js: axios has left package.json, and rss-parser
is used elsewhere.
required-inputs.md gives the order by pointing at the AI Engine page, not
as positions.
- The break-glass secret seeder runs again (#814).
scripts/cutover/06-seed-secret.ps1 is for seeding a Key Vault secret when
the site is down. It built its list of allowed names from infra/main.tf
alone. Since the split (#269) that file holds no references, so every run,
-Mode List included, stopped at "Parsed no secret names". It now reads
every .tf file in infra/, as functions/test/terraform-source.js does.
New Pester tests (06-seed-secret.Tests.ps1) hold it to the repository as
committed, and the repository-policy workflow now runs scripts/cutover
beside scripts/lab. Its closing hint now prints the real restart and
health-check commands instead of placeholders. The docs that placed the
references in main.tf, or the script's window in admin_ip_rules, now
match.
- The Usage tab names the Playground's calls. aiEngine.chat records
Playground calls as admin_playground, but SOURCE_LABELS knew only the
older admin, so those rows rendered as a raw slug. Both now read "Admin
playground", and a test pins it. variables-and-secrets.md also no longer
claims the timer flags "still resolve to false today": timers have run
since they were armed, so it now says a timer runs when the master flag is
on and its name is in enabled_timers.
- The secrets monitor can succeed again, so delivery health goes green.
monitor-delivery-health's "Can every workflow still succeed?" failed on
2026-09-29 (run 36609411076). The workflow it named,
monitor-unresolved-secrets.yml, had failed 10 of 10 runs and had been
switched off since 2026-09-14. The only references it found unresolved
were AZURE_SPEECH_KEY, already listed as intended, and RSSCOM_API_KEY
and RSSCOM_PODCAST_ID, which wait on the owner's RSS.com Max purchase
(#432, owner decision 2026-09-18).
- Both RSS.com secrets are now in EXPECTED_UNRESOLVED in
scripts/check-unresolved-secrets.mjs, with that reason.
- Run against the live Function App's references, the monitor now exits 0:
all 60 resolve or are unresolved as intended.
- Re-enabled, it catches a reference that should resolve and doesn't, such
as a lapsed Coder status token.
- A provider's name on the AI Engine page follows the code (#701).
#806 renamed "NVIDIA API Catalog" to "NVIDIA API" in DEFAULT_PROVIDERS,
but the card renders the stored ai_providers document, and seeding only
writes a document that is missing. So the old name stayed.
- seedAiEngineIfEmpty now patches each stored provider's name,
description and icon back to the seed on every page load. These are
SEED_OWNED_PROVIDER_FIELDS, which the page gives no way to edit, so a
difference is only ever a stale copy.
- Fields an administrator sets (model, enabled, order, placement) and
unknown providers are untouched.
- providerDisplayPatches is pure and tested, including the NVIDIA
document as it was stored on 2026-09-25.
- A slow first AI provider now fails over before the browser gives up
(#701). Generate draft is a synchronous call. The browser waits 90 s and
the edge about 100 s. NVIDIA is first for drafting and was allowed 120 s per
attempt, and on 2026-09-29 its reasoning models took 56-58 s to answer one
word. So the failover to a paid provider started after the page had timed
out, and the editor saw a timeout although Gemini would have answered.
- Synchronous routes now give the AI router a time budget (budgetMs).
Half of it is held back for the next provider, and each attempt's timeout
is cut to its share. A retry is made only if a whole attempt still fits.
The contract is in the header of functions/src/lib/ai/router.js; the
arithmetic is in functions/src/lib/ai/time-budget.js.
- Routes with a budget: Generate draft and Create from recording (the
existing 75 s handler budget, less 5 s for the work after the model),
regenerate newsletter intro and suggest subject lines (14 s), and the
social caption Generate button (15 s). The last three answer under the
browser's 20 s default.
- A call that runs out ends with AI_BUDGET_EXHAUSTED (HTTP 504). It names
each provider tried, why it failed, and which were never reached.
- Background paths pass no budget and are unchanged: forge jobs, the
schedulers, the change feed and the queue workers. NVIDIA keeps 120 s
there.
- Gemini and Anthropic now honour a per-call timeoutMs, as OpenAI and
NVIDIA already did. The portal's Test passes 45 s, so it now bounds those
two at 45 s instead of 60 s.
- sync-budgets.test.js pins each budget under the browser's timeout in
frontend/src/lib/api.js and the edge's 100 s. The functions CI row now
also runs when api.js changes.
- The AI Engine's Test for NVIDIA answers in seconds, and the card reads
"NVIDIA API" (#701). The one-word Test ran with the drafting limits
(8,192 tokens, 120 s). NVIDIA's models are reasoning models on a trial
tier, so it took 56.3 s and 57.9 s on 2026-09-29 (AppRequests
testAiProvider). A slower model outruns the edge's ~100 s request limit
and never reports.
- The Test now asks for 16 tokens inside 45 s (TEST_MAX_TOKENS and
TEST_TIMEOUT_MS in functions/src/lib/ai/proxy.js).
- callProvider passes both through to providers whose row sends
max_tokens. That is NVIDIA; OpenAI never gets one, because its
reasoning models refuse the field.
- A stuck provider now fails with "timeout after 45000 ms" instead of
hanging.
- The card and the keys-page label drop "Catalog".
- Required-Inputs marks NVIDIA-API-KEY as seeded.
- The home page no longer shows invented numbers or sample content, and
two small lines on Home and Learn pass contrast. Every figure and list on
/ is now read from something real, and homeContent.js says where each
one comes from.
- Quick Access Hubs. Each hub printed a typed feed count: Azure said
24 while the server reads 7, AWS 32 against 3, GCP 18 against 3,
Terraform 12 against 1, GitHub 9 against 4 and FinOps 7 against 1.
VMware, Ansible and Docker said "NEW HUB". Each note is now the number
of feeds that provider's news page reads, from
frontend/src/data/providerFeeds.js. That file is a copy of
PROVIDER_FEEDS in functions/src/lib/rss/feeds.js, the list the
ingest actually fetches. providerFeeds.test.js fails if the two lists
differ by a feed, name or address. HomePage.hubs.test.jsx checks each
rendered count against the server file itself. The frontend CI job now
also runs when that file changes. ProviderContext's rssFeeds reads
the same list: it still named Azure Updates' retired CDN feed and
Ansible's old address.
- Stat row. The row showed 24+ blueprints, 104+ articles, 22+ modules
and 99.9% uptime.
- Blueprints is now the count of the staticBlueprints the architecture
pages export (24).
- Articles is now the published total that GET public/content
reports (24 on 2026-09-29).
- Modules is gone: the Terraform modules page lists six cards, and
nothing counts 22.
- Uptime is gone: the availability probe reports only to monitoring
that a visitor cannot see.
- "Latest from the providers" was ten hard-coded sample headlines,
stamped "2 HRS AGO" to "4 DAYS AGO", with a "Last 24 Hours: 3" count.
It is now "Latest articles": the six newest published items the blog
pages read, fetched in the browser. Each card opens its own page and is
dated from the article. Loading, empty and error states use visitor
wording. The pre-render shows the loading line rather than a list
frozen at build time.
- Blueprint carousel. Seven of its ten hand-typed cards opened
"Architecture Not Found", and every card said "Snapshot v2.0". It now
rotates the 24 real blueprints. AWS's six open their own pages; the rest
open their provider's architecture page, where they are shown. The
section is now "Reference Blueprints: Architecture Designs by Provider",
not "from Cloud Vendors".
- Platform Health.
- The last row said "GitHub Actions" over GitHub's overall status. It
now says "GitHub".
- Each row now links to that provider's own status page. Two of them
used to open an unofficial AWS feed and Azure's products-by-region
table.
- The timestamp is now the server's checkedAt, not the time the page
asked.
- A failed check now reads UNKNOWN instead of staying on CHECKING.
- Its light-mode badges took the -800 shade the site's other status
badges use. OPERATIONAL measured 3.41:1 and REGIONAL IMPACT 3.03:1.
- Contrast.
- CatalogueFreshness, the "Catalogue checked against …" line, measured
3.28:1 on the /education tiles in light mode. The home page's "last
verified" timestamp measured 4.01:1 in dark mode. Both now use the
text-muted-foreground token, and tests pin both classes.
- /education is now in e2e/contrast.spec.js and
scripts/axe-theme-scan.mjs.
- Axe finds no colour-contrast violation on / or /education in
either theme.
- A Google beta that has closed reads "Coming", not available (#770).
deriveStatusinfrontend/src/lib/certStatus.jsused to promote everybetarow toactivethe day after itsbetaEndDate. That is right for Microsoft, whose betas go live as they end. It is wrong for Google, which closes the beta window and opens GA later: "Beta participants will be notified of their results 4-6 weeks after both the exam window and lab window are closed. You can pass the beta or GA certification" (the Professional Agentic Architect page, re-read 2026-09-29). - A row can now say
betaClosesBeforeGa: true. From the day after itsbetaEndDateit rendersupcoming, "Coming · beta closed · GA date not announced", or "· GA" when a gaDateis published. It turnsactiveon thatgaDate. - GCP's PAA carries the flag. So from 2026-10-01, until the row is updated to what Google did, the page no longer offers an exam nobody can book.
findStaleStatusesstill fails on 2026-10-01 for PAA, AZ-800 and AZ-801. That is the reminder, and #770 carries it.- Lab image publishing: the storage-record warnings are gone, and a refused Docker exchange explains itself.
- Both publish jobs in
publish-lab-image.ymlnow holdartifact-metadata: write, which the attest step needs to write its storage record to GitHub's linked artifacts page. Without it every run warned "Please check that the artifact-metadata:write permission has been included" and "Failed to create storage record" (run 36508154993). - That run's Docker Hub job failed at the token exchange with only
400 {"error":"access_denied"}. A new step,Explain a refused Docker token exchange, runs only when the login fails. It requests an ID token for Docker's audience and writes itssub,aud,repository,refandjob_workflow_refclaims to the run summary (never the token), beside the rule the runbook asks the connection to hold, and says whether they match. - The runbook's failure table points to that table and to the connection's Failures table in Docker Home.
- The OFL fonts carry their licence, and a lab pane test no longer races.
- Google Sans Flex (
frontend/public/fonts/google/) and Mona Sans (frontend/public/fonts/github/mona-sans/) are under the SIL Open Font License 1.1, which requires its text to travel with the files. Neither had it. Each now has anOFL.txtcopied from its official repository:google/fontsofl/googlesansflex/OFL.txtandgithub/mona-sansOFL.txt. - Amazon Ember and Bookerly are proprietary and need an owner decision, tracked in #791.
- In
LabPanePage.test.jsx,launcherSaysnow flushes effects before posting. The page attaches itsmessagelistener in an effect, which may not have run when the pane's commit came from the status read resolving outsideact. A message sent then was lost, and "shows the launcher's state on the toolbar" failed once in CI on 2026-09-29. Every call is now awaited, and five consecutive runs pass 45 of 45. Register-LabArc.ps1 -Connectno longer fails its own clean-up. After deleting the onboarding secret, the script checked what was left with@(Get-LabArcPasswordCredentials …).Count. That function returns its list as one object (return , …), so the count was always 1, whether the registration held no secret or two. On 2026-09-28 the owner's run connected the lab VPS to Arc and deleted the secret from Entra, then stopped before removing the fourvault_arc_*keys from the host's vault, installing the Azure Monitor Agent and associating the data collection rule. The newConfirm-LabArcCredentialsGoneassigns the list before filtering it. It also re-deletes and re-reads up to six times ten seconds apart, because Entra's reads can lag its writes. The vault keys are now removed before that check, so a slow Entra read can't leave them behind. Four Pester tests drive it through the realInvoke-LabAzJsonwith only the az seam mocked, including the one-object case. The same session finished the owner's run with the fixed script, and it ended with the host Connected, the secret gone from Entra and the vault, and the agent sending todcr-lab-hybrid-prod-cus.- Primary buttons use
text-primary-foreground, every scope's pair reads, and provider logos read on both themes.--primaryis near-black on the light theme, white on the dark one, and the provider's own colour inside a provider's pages, sobg-primary text-whiteis a blank white box on any dark page outside a provider scope (the two incomponents/labswere, until #767), andbg-primary text-foregroundis unreadable in one theme or both. 37 class strings in 15 files now puttext-primary-foregroundon a solidbg-primary,hover:pairs included, among them the icon tile on the news and listing pages, whosetext-black dark:text-whiteicon sat in a child element a grep does not see. Measured in Chromium, most were readable where they render today, because a provider scope set a saturated primary; the switch renders the same there and stops being wrong the day the component renders anywhere else. Two scopes' own pairs were under WCAG AA, and changing the class alone would have made them worse: AWS put black on its darkened orange (4.31:1, now white, 4.88:1) and FinOps white on its green (3.02:1, now the site's dark ink, 5.67:1), so the FinOps tools page's "Launch Tool" buttons, 3.01:1 on the dark theme, are 5.67:1 on both.src/primary-foreground.test.jsholds both halves: every scope's--primary-foregroundon--primarymeets 4.5:1 on both themes, and a scan of the parsed source (string and template literals, each branch of a conditional, everycn/clsxcombination, each variant state, and the JSX inside a primary surface) fails on any other text colour on solidbg-primary; translucent tints such asbg-primary/10are allowed, and the allowlist is empty. On the home page,aws.png's white lettering vanished on the light theme andansible.svg's near-black disc on the dark one; the light theme now shows AWS's one-colour logo as AWS draws it in its own header (aws-on-light.svg; its trademark guidelines forbid recolouring) and the dark theme the reversed Ansible mark (ansible-on-dark.svg, the Simple Icons path, CC0), each with its source noted in the file. The GitHub mark was nearly invisible on the dark theme for a different reason: the pre-renderer resolves the theme as light, and hydration keeps the server'ssrc, so the black Invertocat stayed on a near-black page.ProviderLogonow renders both variants and lets.darkhide one, so the static HTML is right for both themes and the hidden file is never fetched. One table,lib/providerLogos.js, replaces the strip'slogo/logoDarkfields and the home page'sgetProviderLogoSrc, whose design cards had been asking for aterraform.pngthat never existed. - Coder sends no usage telemetry. Owner decision 2026-09-28. Coder
v2.37.3 ships with
--telemetryon by default ("Coder collects anonymized usage data").lab-host/coder/docker-compose.ymlnow setsCODER_TELEMETRY_ENABLE: "false": the site's privacy policy names no such processor, and the data would come from learners' sessions.template.test.mjspins it, and/api/v2/buildinforeports"telemetry": falseafter the lab host's nextbootstrap.shrun. The lab editor's own telemetry was already off (#764). - Coder token steps hold the token in a variable, not on the clipboard.
In
lab-host/README.md, the owner-check, template-publish and status-token lines all read a Coder token withGet-Clipboard. Each of those lines is itself copied from the README, so on 2026-09-28 copying the status-token line replaced the token, and Coder answeredYou are signed outto the line's own text. The token is now read once into$tat a maskedRead-Hostprompt. The new status token is captured in$sand goes to the clipboard only for the paste into the keys page, after which nothing else is copied. Thehcw-coder-template-pushtest pins the new line. CODER_STATUS_TOKENmust resolve. The owner seededCODER-STATUS-TOKENon 2026-09-28 (#682). The site's Coder card then listedhcw-laband a running count, so the token came offEXPECTED_UNRESOLVEDinscripts/check-unresolved-secrets.mjs, and an unresolved reference is a failure again. The token expires on 2027-09-28, and #763 is the renewal reminder.- The runbook's "Validate on the lab" table matches what visitors see.
docs/runbooks/labs-host.mdkeyed its troubleshooting table on the button's old wording, such as its browser check (Cloudflare Turnstile) is not configured, which #755 took off the public pages. It is now keyed on the APIcodein the network response, with the exact visitor sentence for each, what it means and what to do, and rows for the states with no code, told apart by the page'sdata-dooranddata-check. -
The lab article drafts link to each other on the site, not to repository files. The six sibling links in
docs/content/blog-lab-0*.mdpointed atblog-lab-0N-*.md, which works on GitHub only; they now name each article's site URL,/<provider>/blog/<slugified title>(Azure for part 1, Terraform for parts 2 and 3). A title changed before publishing changes the slug, so change these links with it. The importer's test now asserts the drafts carry no repository-link warning. -
The editor's Problems panel matches CI, and the one open code-scanning alert is fixed.
.vscode/settings.jsonexcludes.claude/worktrees/(agents' temporary checkouts, which fed staletsconfig.jsonand workflow diagnostics) and runs flake8 with ruff's default rule families (E4,E7,E9,F), the rules Qlty's ruff enforces; without it the editor flagged 79-columnE501on every Python file CI accepts.hooks/claude_event.pyreports a lost audit record on stderr instead ofexcept OSError: pass(CodeQLpy/empty-except, alert 363), with the test asserting it.required-inputs.mdnow recordsCOPILOT_REVIEW_APP_PRIVATE_KEYas MISSING (found absent from the Agents store 2026-09-28), andcopilot-setup-steps.ymlexplains why the Actions extension flags its Agents-store secrets. -
Register-LabAgent.ps1pauses for the registration instead of failing. The owner's first live run (2026-09-27) created the Entra app, its service principal, the certificate and theLabAgentgrant, then stopped with "Cannot bind argument to parameter 'Lines' because it is an empty string": the registration prompt has blank lines, andWait-LabRegistration's mandatory[string[]]refused them. It is now[AllowEmptyString()], with a test that feeds it the real prompt. -
The lab's HTTPS turns on: Caddy's config check loads the token. The
caddyrole validated the Caddyfile without the environment file the unit reads, so the Cloudflare token was empty during the check and the TLS Caddyfile was refused ("API token '' appears invalid") on 2026-09-27, the first run with a vault.validatenow passes--envfile. Proven on the VPS from the branch before merge:failed=0, andhttps://lab.hybridcloudworks.comserves a Let's Encrypt certificate (valid to 2026-12-26) with HTTP redirecting to it. -
lab-image: the image vendors every module version the Landing Zone Builder emits, and CI fails when it stops (ADR 0032 decision 5). Found while the lab article drafts were verified (#737). The builder's spokes call
avm-res-network-virtualnetwork0.22.2, and the image carried only 0.15.0, as the connectivity module's child. So the full default build failedterraform initunder--network noneand passed online. Reproduced on 2026-09-27 against the image built frommain:hcw-terraform-validateleftspoke_identity,spoke_corp_1andspoke_online_1withno vendored version satisfies it (have: 0.15.0), and init failed reachingregistry.terraform.io.
The list of builder modules is now written once. Each is an
AVM_<KEY>_VERSION / AVM_<KEY>_SHA256 pair in lab-image/versions.env,
and the Dockerfile's fetch stage vendors every pair it finds instead of
three named calls. 0.22.2 joins the three pattern modules, verified by its
release tarball's SHA256, with its one new child,
avm-utl-interfaces@0.6.0, pinned by tree hash. 0.15.0 stays beside it,
so the two versions coexist as two /opt/avm/<name>@<version>
directories. A new AVM_CHILD_CALLS block records which builder module,
at which version, reaches which child. vendor-avm.sh prints it with
AVM_PRINT_PINS=1 and fails the build when it differs from what
terraform get resolved. It also resolves a child at a builder module's
exact name@version to that builder copy rather than vendoring it twice.
The path layout hcw-terraform-validate reads is unchanged. All sixteen
existing child hashes came back identical.
Three checks enforce it. scripts/lab-image-avm-vendoring.test.mjs runs
in the required scripts (operations) job with no Docker and no network,
and fails when versions.env and avmVersions.js disagree about any
module, version or recorded child. Against main's files it reports 23
problems, the first being the missing 0.22.2, and it feeds itself a
bumped pin to show that goes red. smoke.sh derives the builder modules
from versions.env, checks every recorded child is on disk, and inits
each builder module offline. sandbox-check.mjs now validates the
builder's real full default build, emitFiles(DEFAULT_STATE) generated
at run time, through the agent's own docker-runner.js. Every one of its
six registry module blocks must be rewritten, and it ends Success! The
configuration is valid. offline. publish-lab-image.yml runs on pull
requests that change frontend/src/lib/landingZone/ for that reason. The
weekly update-avm-versions.yml pull request now says its scripts check
stays red until the child blocks are re-printed. That is by design,
because the children of a new release are unknown until the build
resolves them.
The builder's "Validate on the lab" prediction (labImage.js, #672) now
takes its builder modules from AVM_MODULES directly and holds its one
child list to AVM_CHILD_MODULES. The full default build, spokes
included, is accepted and sent. A module version the image lacks is still
refused, with the module named.
One limit is not the image's. At the agent's default job memory of 256m,
that build is OOM-killed during terraform validate (docker inspect:
OOMKilled true, exit 247), after an offline init that succeeds. Its
sixteen provider configurations each start a plugin process, and 320m
passes in 20 s. The host's labs_agent_job_memory is also 256m, and
raising it is an owner decision, so sandbox-check.mjs runs that one case
at 512m and names the override in its output. Image sizes are unchanged
at the README's precision (1.71 GB / 319 MB runner, 2.73 GB / 497 MB
full). /opt/avm grew from 5.7 MB to 7.5 MB and each image by 2.1 MB.
-
An unresolved Key Vault reference is never the Cloudflare origin secret. If
CF_ORIGIN_SECRET's Key Vault reference failed to resolve, the app read the literal@Microsoft.KeyVault(...)text, which is public ininfra/functionapp.tf, andclient-identity.jsaccepted it as proof a request came through Cloudflare, so any Cloudflare-range caller could pick its own rate-limit key. Now that value (andCLIENT_IP_SALTthe same way) counts as unset and anonymous routes fail closed. Found by the security review of #738. -
npm testinfrontend/runs on Windows again: the crash was pdf-parse's canvas addon, not the pool or Node 26 (#720). On Windows the suite died partway with0xC0000005(access violation). It did so in 4 runs of 11 under Node 26.10.0, 4 of 12 under 26.5.0 and 6 of 10 under Node 24.21.0, so it was never Node 26's. The pool looked guilty and was not:threadscrashed 9 runs of 10 andvmThreadsabout a third, whileforksandvmForkscrashed none of 10 each. One file crashed on its own,scripts/update-applied-skills.test.js(4 runs of 6 underthreads); all 183 files insrc/together crashed none of 5. It imports pdf-parse, whose pdfjs-dist loads@napi-rs/canvasat import, and 0.1.80, the version pdf-parse 2.4.5 pins, kills the whole process when a worker thread that loaded it exits. A probe with no Vitest in it (one worker loads the addon and exits) crashed 8 runs of 10, and loading it on the main thread only crashed none of 12. Linux never crashes, which is why CI stayed green. Vitest 5.0.2 has no fix for this and needs none.
package.json overrides @napi-rs/canvas to ^1.0.9. The lockfile changes
only its own platform packages, and pdf-parse's text for both catalogue
posters is byte-identical under either version. With the probe scaled to
ten rounds of eight workers, 0.1.80 crashed 17 runs of 17, 1.0.5 8 of 30 and
1.0.9 none of 90. A plain npm test then passed
on Windows 30 runs of 30 under Node 26.10.0 (11.0–13.6 s), where
--pool=forks, the only way through before, took 31.1–33.7 s. It also passed
10 of 10 under threads, the pool that had crashed 9 of 10, and 5 of 5 each
under 26.5.0 and 24.21.0. The pool stays vmThreads on every platform, so
CI's Linux run is unchanged. scripts/pdf-parse-canvas-worker.test.js holds
the override on every platform. It also runs the probe in a child process,
so on Windows a regression fails with its cause named. Against 0.1.80 both
of its tests fail. vitest.config.js records all of this beside the pool.
frontend/: 191 files, 2,669 tests.
-
tfc-plan-checkcompares every attribute of an update, not only the Function App'sapp_settings(#719).scripts/assert-expected-plan.mjsreadapp_settingsalone, so #718'sruntime_version "22" -> "24"passed unexamined. Every update in the plan is now comparedbeforeagainstafter, leaf by leaf, by a newscripts/lib/plan-diff.mjs. Each difference is named by its path (runtime_version,site_config[0].http2_enabled,app_settings["AzureWebJobs.x.Disabled"]). The allow-list is unchanged: the per-applyRUNTIME_CONFIG_WRITERline and the three azapi replacements. Any other difference fails the check. The repository is public, so the job summary is too, while plan JSON carries secrets in plaintext: a local Terraform 1.15.8 plan showed them inbefore,afterandvariables, one left unmarked interraform_data's computedoutput. Soscripts/lib/plan-report.mjsprints only what is safe to publish. Sensitive values print as(sensitive), unknown ones as(known after apply), a recomputed list or object by its size, andapp_settingsvalues as(set). Any string of six or more characters the plan marks sensitive (in resources, outputs or sensitive variables) is masked wherever else it appears, and so are GUIDs and email addresses.runtime_version: "22" -> "24"still prints in full. An update missing its sensitivity markers, or a change with no action list, exits 2. An unrecognised action such as["forget"], an import and a move are now UNEXPECTED; they used to pass as no-ops (scripts/lib/plan-actions.mjs). The JSON parser's message, which quotes the plan, is no longer printed, and only the first line of the run message is. The job summary names the ref and sha the checker ran from, and a green verdict from a branch other than main no longer says "Safe to confirm". An intended change passes only when the pull request that makes it adds an entry toDECLAREDinscripts/assert-expected-plan.mjs, with the exact before and after values. The entry then matches nothing once the change has applied, and is reported as a NOTE to delete rather than a failure. A sensitive or unknown change cannot be declared.check-tfc-plan.mjsprints the declarations, andtfc-plan-check.yml's header says the declarations read are those on the dispatched ref. The tests use a plan-JSON fixture,scripts/fixtures/plan-permanent-diff.json, whose attribute names are held toinfra/functionapp.tf.DECLAREDships empty: the live app already runs Node.js 24. -
Audio from the API plays, and upstream failures reach the page. Two site-wide faults found on the Audio tab on 2026-09-26. The CSP had no
media-src, sodefault-src 'self'blocked every<audio>streaming fromhttps://api-azure.hybridcloudworks.com(Listen & Learn episodes, the Recording Hub, the ElevenLabs live check), which showed0:00; the CSP now hasmedia-src 'self' https://api-azure.hybridcloudworks.com, held bycsp.test.js. And Cloudflare replaces an origin 502 or 504 with its own page, which has no CORS headers, so every handler answering 502 for a failed upstream (Resend, the AI router, the voice preview, ...) reached the browser as "Failed to fetch";httpRoutenow sends those as 500 withX-Upstream-Status, and the handler's reason arrives intact. -
An ElevenLabs refusal now says why, in ElevenLabs's words. The first live voice listing on 2026-09-26 answered a key that had just read the subscription with
401and codeunauthorized, which the Audio tab showed as "ElevenLabs rejected the key". A permission refusal is now read fromdetail.code, the legacydetail.status, or a 401/403 whose own sentence names a permission, and every refusal appendsElevenLabs said: "..."(one line, 300 characters, anything shaped like a key replaced with[key]). -
The lab host's first run survives a freshly booted VPS, and SSH hardening can no longer be lost to a failed run. On the reinstalled VPS on 2026-09-26 the first
bootstrap.shrun failed when a first-boot unattended-upgrade held the dpkg lock during the Docker role's package hold, and the sshd drop-in it had already written never took effect: the end-of-play restart handler did not run, and the next run found the file unchanged. Now everyapttask waits up to fifteen minutes for the lock (module_defaultsinsite.yml,DPkg::Lock::Timeoutinbootstrap.sh), the threedpkg_selectionsholds retry for ten, the hardening role flushes its handlers straight after the sshd drop-in, andforce_handlersruns notified handlers even when a later task fails. -
Coder no longer redirects to its access URL behind Caddy.
CODER_REDIRECT_TO_ACCESS_URLisfalseinlab-host/coder/docker-compose.yml: Caddy terminates TLS and forwards plain HTTP, and Coder v2.37.3 answered every such request with a 307 to the https access URL, a loop (measured in #722). A test intemplate.test.mjsnow holds the value. The Coder README's check command uses a glob that works on Node 26, and its expected count ispass 10. -
The lab host runs the commit it is given, not a pin that lags one merge behind.
lab-host/bootstrap.shpinnedHCW_REPO_REFto04aa9e36, a commit from beforelab-host/existed, so a first run checked it out and stopped atcd lab-host/ansible. The deeper fault was structural: a pull request cannot pin its own merge commit, so the documented "moveHCW_REPO_REFandlabs_agent_repo_refin the same pull request" always named the commit before the change, and every lab-host change needed a second pull request to take effect.HCW_REPO_REFnow defaults toorigin/main. After the clone or fetch it is resolved once withgit rev-parse --verify "${HCW_REPO_REF}^{commit}", and that full sha is checked out detached and printed before the play and again as it starts.main's ruleset (a pull request for every change, no bypass actors, strict required checks includingansible-lint (lab-host)andcoder (lab-host)) gives the trust the pin gave.HCW_REPO_REF=<sha or ref>still holds or rolls back a host, and an unknown ref stops the run before anything changes. When the resolved commit carries a differentbootstrap.sh, the running script hands over to it, so a uv, Python or ansible-core bump takes effect on the run that fetches it rather than the one after.labs_agent_repo_refingroup_vars/all.ymlnow reads the full sha of the playbook's own checkout, so the agent runs the same commit, including whensite.ymlis run by hand, and an explicit-e labs_agent_repo_ref=<sha>still holds it back. Thenpm cistamp is keyed on the sha the checkout task lands on rather than on the ref string. Stamps left by other commits are now removed first, so releasing a hold reinstalls rather than keeping the held commit'snode_modules. Documented inlab-host/README.md("Which commit runs", with runnable hold and rollback lines), thelabs_agentrole README and argument spec,docs/runbooks/labs-host.md,lab-host/coder/README.mdandinfra-lab/README.mdstep 7. Verified in a privileged systemdubuntu:26.04container from a bundle of the branch, with coder and arc off and no vault. The first run wasfailed=0and named the sha, and the second waschanged=0. Aftermainmoved in the bundle, the plain re-run moved the playbook and the agent to the new sha and reinstalled (changed=3).-e labs_agent_repo_refheld the agent at the previous commit while the playbook moved, and releasing it reinstalled. A--check --diffrun and a further run werechanged=0, an olderbootstrap.shhanded over, and an unknown ref was refused. -
The Azure detail page's catalogue walk no longer fails at random in a full suite run (#640).
CertDetailPage.test.jsx's resolves every landing-page slug to a detail page failed once on 2026-09-17, passed on its own, and passed on a re-run of the same suite — the shape that teaches everyone to re-run rather than look.
It was not cross-test interference, which is what #640 assumed and wrote
down as the first thing to check. This suite runs on vitest's defaults (pool
forks, isolate on), so every test file gets a fresh module registry: a
probe that mutated a shared module object and set a global in one file found
both pristine in the next. Neither module state nor a stray mock can cross a
file boundary here, which leaves file ordering — the third hypothesis —
nothing to expose.
It was a budget. The walk renders all 101 Azure exam pages, and each one
suspends on a real dynamic import of that exam's study-guide module, so the
test goes through Vite's transform server ~51 times — while in a full run
that one server is serving four worker processes at once. None of that
contention exists when the file runs alone, which is exactly why it passed
alone. Measured on the 4-core container: 1.4 s alone, 1.67-2.19 s across
four full runs, against vitest's 5 s default. One slow moment covers the
rest of that distance, and this suite has them — App.routes.test.jsx's
un-mocked lazy route, the same shape, swung 1.05 s to 2.70 s between two
runs of the same suite on the same machine. Oversubscribing the CPU
reproduces it on demand: 4.75 s in a full run, and, running that file
alone, Error: Test timed out in 5000ms. at 5,083 ms — the failure mode
named, not inferred.
Both tests now state the budget their own work needs: 30 s for the walk,
which is the figure test:coverage had already settled on naming this exact
test, and 20 s for the route. The route's was a second and quieter bug — it
asks findByText to wait up to 10 s, which under a 5 s test budget it could
never be given, so the generous timeout added for CI load had never once
applied.
- Gallery uploads no longer refuse a file for having the wrong name (#631).
getGalleryFileExtensiontook the blob path's extension from the filename while the content type came fromfile.type. The upload route requires the two to agree (checkUploadMediaType), so a valid file with a name that disagreed was a 415:photo.jfif— what Windows has written for JPEGs saved from a browser for years — and any PNG someone had named.jpg.
It uses imageExtensionFor now, as the submit page's slot uploads did in
#632, which derives the extension from the declared type. imageUpload.js
had documented this exact trap and carried PUBLIC_IMAGE_EXTENSIONS to avoid
it; both call sites were simply not using it. An unsupported type is named
outright rather than building a path the route then rejects for an
unrelated-looking reason.
- Stage 3's "Upload Images" half had never worked (#630). Article slot
images on
/admin/submitwent to thecontentblob container. Every container is private in Terraform — "public" means reachable through the media delivery route, and only the containers inPUBLIC_MEDIA_CONTAINERSare — so the upload route answered 200 withurl: ''by design, and the page stored that empty string.
An empty string is falsy, so {slotUrls[key] && …} never rendered the
uploaded row. The operator pressed Upload, the spinner finished, and nothing
appeared: no error, no link, no confirmation. Downstream,
collectSelectedSlotImages drops the slot on .filter(Boolean(item.url)),
so canPreview and the article's hero were computed as though no image had
been uploaded at all. Only AI-generated images worked — and the reason is
instructive: that path has carried if (!imageUrl) throw all along, which is
exactly the guard the upload path lacked. It has one now.
Slot images go to covers, the container Terraform describes as "content
cover images, served via the media route". Nothing already in content
becomes reachable.
SVG is no longer offered, and nothing is lost by that. admin-uploads.js
recorded that this picker offered SVG because these went to a private
container; in a publicly served one it is refused, since served anonymously
an SVG is a scriptable document. An SVG slot upload produced url: '' like
every other type, so it never worked either. GIF and AVIF are newly offered.
The blob path's extension now comes from the file's declared type rather
than its name (imageExtensionFor), which is half of #631: the route
requires the two to agree, so photo.jfif — what Windows writes for a JPEG
saved from a browser — was a 415 on a perfectly valid file.
The write is module-level over a state bag, as linkWrites.js is, so it has
seven tests without mounting a 2,859-line page.
- Gallery uploads produced images nothing could display (#602). Manual
uploads on
/admin/gallerywent to thecontentblob container. Every container is private in Terraform — "public" means reachable through the media delivery route, and only the containers inPUBLIC_MEDIA_CONTAINERSare — so the upload route answered 200 withurl: ''by design, and the page wrote that empty string into the gallery record. Nothing failed: the image stored, the record saved, and the result was a row that rendered no thumbnail and handed an empty hero URL to the submit page through "Use this image".
Uploads go to covers now, which Terraform describes as "content cover
images, served via the media route" — exactly what a gallery image is used
as. Nothing already in content becomes reachable; the alternative of
adding content to PUBLIC_MEDIA_CONTAINERS would have exposed everything
already sitting in it, which is the disclosure decision blob-paths.js warns
about, and it is not taken here.
Two things stop this being silent again. The page now refuses to create a
record when the route returns no URL, so a container that cannot serve
fails loudly at the upload instead of persisting a broken row. And the file
picker offers only the five types a publicly served container accepts,
naming any rejected file rather than letting the route answer 415 for the
whole batch — covers refuses SVG, because served anonymously it is a
scriptable document.
ImageGalleryPage had no tests; it has six, including the empty-URL case
that is the defect itself. Writing them surfaced a second bug in the fix:
the rejection message was being set on deleteError, which fetchGallery
clears on every refresh — and the refresh runs immediately afterwards, so
the message was wiped before it could be read. It has its own state now.
Changed¶
- The App installation token is revoked when the job ends (#641). It was living out its full hour after the work that needed it had finished.
revokeInstallationToken calls DELETE /installation/token, authenticated
by the token itself rather than the App JWT, and the two PR-opening
workflows call it in a step with if: always() — so a failed push or a
failed gh pr create still cleans up, which is the case where a live
credential matters most.
It cannot fail the job. The script returns 0 whatever GitHub answers, and counts a 401 as success because an already-invalid token is the end state wanted. A run that did its work should not turn red over cleanup.
copilot-setup-steps.yml deliberately does not revoke, and now says so
at its own mint step. It hands the token to Copilot's MCP server, which
reads it after that job finishes; revoking there would leave code review
holding a dead credential. Making all three consistent would have broken it.
This is what actions/create-github-app-token does in its post step and the
one thing the in-repo minter did not. The header's reason for preferring the
in-repo version is also corrected: it claimed the action's commit SHA could
not be resolved when it was written, which was a limitation of that session
rather than an argument. The real reason — no third-party code in the job
holding the most powerful credential in the pipeline — still stands and is
what the header says now.
Five tests, 29 in that file.
- Six
secrets-outside-envfindings fixed, and the triage record corrected (#567). The three workflows that never adopted the repository's own convention now scope their secret-holding jobs toenvironment: automation.
These had been reported as resolved and were not. The 2026-09-16 verification on #567 said zero zizmor findings at all three personas. Six mediums stood at the auditor persona, untriaged. A persona is not a severity filter — the counts table now reads 42, not 36.
It was never a new decision: production gates the two deploys, copilot
the Copilot setup, github-pages the docs. These three simply never
followed. It is also what Microsoft's MCSB v2 DS-3 asks for, at criticality
Must have.
automation and not production, deliberately. These jobs open pull
requests and read a Terraform plan rather than deploying, and two run on a
schedule. A required-reviewer rule would leave a scheduled run waiting up
to 30 days, not failing — so the environment must stay ungated, which the
triage record now says outright.
The repository's own OIDC guard caught an imprecision, which is what it is
for. scripts/oidc-subjects.test.mjs failed, claiming the Azure login in
publish-content-manifest.yml would now present an :environment: subject
and fail with AADSTS700213. It scanned the whole FILE for environment:
and for the client-id without asking which job held either. GitHub composes
the subject per job, and the login job declares no environment. The
scanner is per-job now, which is strictly stronger: the old shape could not
see a file where one login job names an environment and another does not.
Nothing in a job that calls azure/login was touched.
Half of this is the owner's, and the order is load-bearing. Declaring the environment changes nothing by itself — a repository secret is still readable from a job in any environment. The control arrives when the secret becomes an environment secret, and that must happen after this lands: reverse the order and the job resolves an empty secret, reports "not set — skipping" and passes green while the automation is dead.
Also recorded so nobody re-researches it: TFC_TOKEN has no OIDC
alternative. HCP Terraform's API takes a bearer token and nothing else.
- The Stage cards move out of SubmitUrlsPage, finishing #634. The page is 2,916 lines to 775 across five PRs, and no function in it or its twelve modules is over the complexity gate any more.
All four cards, the page chrome and the section-block data are now their own
modules. StageTwoCard (CCN 10) and StageThreeCard (CCN 13) came apart into
sub-components on the way: Stage 2 drew the same removable-row list three
times, for files, document URLs and KB articles, differing only in the
remove handler.
Moving them exposed five references the new files never imported —
PROVIDER_OPTIONS, BLOG_LANDING_ZONE_OPTIONS, getPublishTargetLabel
(twice), isSupportedDocumentUrl, and a namespaced imageStage. call — and
npm run lint reported 0 errors for every one. ESLint does not run
no-undef on these files, so an unbound identifier is a ReferenceError at
render time and silent everywhere else. Each would have broken the admin page
the first time an operator opened it.
So the cards now have smoke render tests. That is the same failure as #629,
where a rename left three <select> blocks pointing at dead names while
lint, build and every unit test passed because nothing rendered them.
Qlty raised four similar-code findings on the extracted
contentBlocks.js, and they were right. Twenty section-block entries each
wrote their heading out three times — as title, as heading, and again at
the head of template. A block builder derives the last two now.
That closes a latent bug as well as the duplication: the readiness checklist matches a section by its heading while the button that inserts it is labelled by its title, so an edit to one that missed the other would quietly stop the checklist finding a section visible in the draft, and the draft could never be marked ready. All twenty already held the invariant; it is now unbreakable and tested. The file is 182 lines to 65, and the regenerated values were proved identical to the originals before the swap.
Tests 175 to 193.
- Stage 1's machinery moves out of SubmitUrlsPage (#634). Last of the machinery; only the Stage cards' markup is left.
Three modules: pageMeta.js for the pure derivations, promptStage.js for
the prompt library, and builderSnapshot.js for the sessionStorage draft.
loadPromptLibrary (CCN 16) and inferProviderFromUrl (CCN 11) are gone —
the first split so its four cancellation checks are no longer tangled with
its error handling, the second replaced by a table.
Three parallel switches became one table. getPreviewSection,
getPublishTargetLabel and getPromptLibraryPagePath each carried their own
copy of the content-type mapping, so adding a type meant three edits and
three chances to miss one.
The prompt library's status lines were each built twice — once in the load effect, once in the matching change handler — so the wording could drift between the page loading an assignment and an operator making the same one by hand. One definition each now.
The snapshot's read and write lived apart, a module-level reader and an inline effect, which is how two lists of the same fields end up out of sight of each other. Both are in one module with a test that fails if a setter is added to one list and forgotten in the other.
_hasSelectedImage was declared once and read nowhere. That is the second
dead declaration found in this file hiding behind a leading underscore, after
_MAX_STAGE_TWO_SUPPORTING_FILES in #636 — the no-unused-vars exemption
that makes the prefix useful also makes dead code invisible.
Also unified: the uploaded-wins-over-generated rule, which #637 left written
twice in imageStage.js. One slotImageSources now, read by both. All 31 of
that module's existing tests pass unchanged, which is the proof.
Tests 114 to 175. The page is 2,175 lines to 1,784, its total CCN 268 to 164, and its returns 20 to 11.
- Stage 4's persist machinery and StageFourCard move out of SubmitUrlsPage (#634). Third of four.
The create payload and the two ways to save are now in
components/admin/submit-urls/persistStage.js, and StageFourCard — at CCN
20 the single most complex function in the file — is its own module, split
into seven pieces. Its complexity was never one hard decision; it was
fourteen small JSX guards across 215 lines.
Stage 4 is the only stage that writes anything durable, and until now its
payload had no test at all — it was assembled inline in a click handler.
It has twenty-four, including one that pins Live: false and
approvedForBlog: false: saving is not publishing, and if those ever
default true a save silently becomes a publish.
Nothing Stage 4 does has changed. persistContentItem still returns
null on every failure path including the readiness gate, because both
callers branch on it — a falsy id must not produce a success message or
navigate away. Both save buttons are still disabled while either is in
flight, so the draft cannot be written twice. The duplicated Title/title
and Content/content/postContent casings are the stored schema's and
are kept, now with a test saying why.
Also moved: the image-selection helpers joined imageStage.js, where they
belong — which makes visible that the uploaded-wins-over-generated rule is
written twice in that file. The two agree today; unifying them is a
behaviour-adjacent change left for the last stage rather than slipped into a
move.
Tests 76 to 114. The page is 2,587 lines to 2,175, its total CCN 357 to ~278, and its returns 25 to 20.
- Stage 2's draft machinery moves out of SubmitUrlsPage, with tests (#634). Second of four.
Source URLs, supporting documents and the generate-draft call now live in
components/admin/submit-urls/draftStage.js as module-level functions over
a state bag, as Stage 3's did in #635. handleSubmitDraft (CCN 18) and
handleSupportingDocumentUpload (CCN 15) are gone, split into pure builders
(effectiveSourceUrlsFor, draftRequestFor, normalizeDraft) and thin
writers.
Nothing Stage 2 does has changed. The supporting-document read stays all-or-nothing, so one unsupported or oversized file in a batch adds none of them rather than a partial set the operator then has to reconcile; uploads and document URLs still share one five-item budget rather than having one each; and a URL still sitting unadded in the input still counts toward the draft call, so typing one and pressing Generate works without pressing Add.
Two incidental removals. _MAX_STAGE_TWO_SUPPORTING_FILES was declared once
and read nowhere, surviving only because the leading underscore exempts it
from no-unused-vars — and it was a stale duplicate of the constant that
does the work. The six useCallback wrappers around these handlers bought
nothing either: no consumer in this file is memoized and none of the six
appears in a dependency array, so their stable identity was never read.
Tests 31 to 76. The page is 2,770 lines to 2,587, its total CCN 417 to 357, its returns 37 to 25, and functions over the CCN gate 8 to 6.
- Stage 3's image machinery moves out of SubmitUrlsPage, with tests (#634). First of four; the stages are the seams, and the state block already named them.
SubmitUrlsPage.jsx is a 1,088-line component holding 54 useState, 44
returns and nine functions over the repository's CCN gate — and eight
tests, all covering the one function #632 had already pulled out. Two of
its features shipped broken and undetected under exactly that ratio: slot
uploads never worked at all (#630), and a valid .jfif was a 415 (#631).
Both were found by reading the code during an unrelated fix, not by a test
and not by a report.
Upload, generate, remove, resolve and gallery-delete now live in
components/admin/submit-urls/imageStage.js as module-level functions over a
state bag — the linkWrites.js shape the hub series established and that
uploadSlotImageFile already used here. The page keeps the state and five
one-line wrappers.
Nothing Stage 3 does has changed. The count in the "Generated N image
slot(s)" summary is still taken from what was collected rather than from what
was requested, so an abandoned run still reports what it actually stored;
removeGeneratedImage still refuses to clear a slot locally when the
server-side delete failed, which would otherwise leave an empty slot backed
by a live blob.
Tests went from 8 to 31. The page is 2,916 lines to 2,770, its total CCN 472 to 417, its worst function CCN 19 to 5, and its returns 44 to 37; the new module has nothing over the gate.
- The Stop guard's blocking path is tested, and the lint config stops measuring the wrong thing (#588). PR B of the burn-down.
hooks/claude_event.py had one assertion in CI: that it exits 0 with no
state. That is the fail-open half. The blocking half — an armed workflow
with missing handoffs, where the hook is supposed to refuse the Stop — had no
coverage at all, so a regression there would have been silent in the only
direction that matters: the guard quietly never fires and the session ends on
a workflow whose audit trail claims work that never ran.
hooks/test_claude_event.py covers both halves in 13 cases, written and run
green against the old code before any of it was touched.
The nine-branch fail-open cascade inside main is now one function per
question — read_event, audit, armed_workflow_id, validation_fails,
block_decision — each answering "allow" for everything it cannot read, so
the contract is stated once rather than once per condition. main is six
lines. In tooling/workflow.py, the state tree's file names are four path
helpers instead of literals repeated at every call site (a rename could be
applied to the write and missed on the read, and the tool would report
"workflow not found" against a workflow it had just created), and main is a
dispatch table rather than a chain of seven returns.
.editorconfig was measuring indentation in files where indentation is not
a style choice. Of the findings counted on #588, 468 were Markdown — where
four spaces opens a code block, three continues an ordered-list item and two
nests a bullet — and 65 more were PowerShell comment-based help aligning its
continuation lines under .SYNOPSIS, which is what Get-Help renders. Both
now state no opinion on indent_size. .vscode/ states none on
indent_style either: VS Code wrote tasks.json with tabs and the other
three with spaces, so asserting tab to clear the first simply moved the
same finding onto the rest.
Measured with a local reimplementation of the checker's rules (its own binary
cannot be fetched in this environment): 576 findings before, 13 after. The
13 that remain are aligned SQL inside template literals and aligned docstring
continuations — deliberate formatting in code that Prettier and ruff already
govern, and not worth a blanket exemption for every .js file.
- Workflow hygiene: the token is granted where it is used (#588).
zizmorat its pedantic persona reported 34 findings across the 20 workflows; it reports none now, at all three personas.
Five High excessive-permissions. deploy-functions, heal-computed-
properties, monitor-functions-registered, monitor-unresolved-secrets and
verify-alert-state each granted id-token: write at workflow level. Each
has exactly one job, so moving the grant onto that job removes no privilege
that exists today — it stops a second job added later from silently
inheriting the OIDC token. Stated plainly because a "High" here is hygiene,
not a live hole.
Seven workflows had no concurrency block. The monitors and the healer
never cancel a run in flight: a cancelled six-hourly check is a skipped check
rather than a check done later, and heal-computed-properties writes
cp_sortDate onto documents, so an interrupted run stops partway through a
container. The two pull-request checks do cancel, since a new push supersedes
the run it replaced; repository-policy cancels on pull requests only,
because its run on main is the record that what landed satisfies the
policy.
Eleven undocumented-permissions and five anonymous-definition. The
permissions now carry a comment on each entry — zizmor reads a trailing
comment, so several blocks that already had a paragraph above them were still
reported. dependency-review gains name: dependency-review, deliberately
identical to its job id: that job runs on pull_request, so its check-run
name is a status context the ruleset can require, and renaming it would leave
every pull request waiting for a context that is never reported (ADR 0026,
T-523).
Six secrets-outside-env findings remain at the auditor persona only.
Binding those three workflows to a GitHub Environment is a deployment-
configuration change rather than workflow hygiene, so it stays on #588.
Added¶
- Linkie Hub and Labs Hub: finished to the Newsletter Hub standard (#577).
The last two hubs in the series.
/admin/linkieis 127 lines over Links / Analytics / Settings (components/admin/linkie), and/admin/labsis 97 lines over Dashboard / Jobs / Console / Agents / Settings (components/admin/labs), down from 1,076 and 693.
Labs gains two tabs, and they are the point. The job table was the bottom
third of Dashboard with its columns stopping at the exit code, so a job that
failed yesterday could be seen to have failed and not why: job.output was
rendered only for whichever job the Console had just submitted. Jobs rows
expand to it. And the live connection state was the top card of Setup, under
six install steps, so "the agent is disconnected" was answered with
provisioning instructions whether or not anything needed provisioning.
Agents separates the three states fleetState names: online, stale — an
agent that has connected and stopped, which wants systemctl restart and not
a reinstall — and none.
Linkie's Connection tab became Settings with ?tab=connection
redirecting, and ?profile= survives a tab change: every posts and analytics
path is profile-scoped, so a link naming a tab without its profile would land
on whichever profile sorts first.
One live-page rule, shared by the three surfaces that must agree.
isLiveRecord and getLiveUrl existed in LivePagesPage, the Social Hub and
Linkie. isLiveRecord genuinely matched byte for byte; getLiveUrl did not —
the Linkie copy was the ||-chain-into-a-nested-ternary that Qlty flagged on
the Social Hub's copy in #623, carried unnoticed in a second file while the
first was fixed. They are lib/livePages.js now. One difference is
deliberately not resolved: LivePagesPage has a getContentPublicPath
fallback the hubs lack, which changes which pages an operator can post about,
so it keeps its own resolver and the module header records the divergence.
Labs had no tests at all, including the agent-staleness rendering T-309
was filed against; it has 12 page tests and 16 helper tests now. Writing them
found labsView.js calling toMillis without importing it, and found that
four structural guards in labsPolling.test.js had gone blind — they read
LabsPage.jsx as text, and this refactor moved their subject into
components/admin/labs/, so all four passed vacuously against the 97-line
page left behind. They read the whole hub directory now, and assert the glob
matched something.
- Recording Hub: organised by duty, not by provider (#576).
/admin/recording-hubhad one tab per SERVICE — Podcast, and Plaud with three sub-tabs of its own — so "where do I approve a transcript" depended on which service produced it, and the OAuth setup sat two clicks inside a working tab. It is five duties now at the Newsletter Hub standard: Recordings, Transcripts, Episodes, Distribution, Settings. Providers are named in the header and inside the tabs. The page is 118 lines and the two provider components (1,350 and 598 lines) are gone, re-cut intocomponents/admin/recording-hub.
The page had no ?tab= at all. It held the selected tab in useState,
so no link could name a tab and Back could not leave one — the first thing
resolveTab fixes. Both provider ids redirect: podcast to Transcripts, and
plaud to Recordings rather than Settings, because that tab opened on
its Library sub-tab and the recordings are what such a link was for, not the
OAuth setup two sub-tabs along. The Plaud sub-tab ids and the host/feed words
redirect too, and own-properties-only lookup keeps ?tab=constructor from
reaching Object.prototype.
Distribution is new, and is the reason the split was worth doing. The
RSS.com host record was one line tucked under each transcript, so "did
anything fail to publish" meant scrolling the whole list and reading every
row. The same records are now grouped by state with failures first and the
retry beside them. hostState decides the grouping and HostLine renders
by the same four states, rather than two if chains that could drift.
Transcripts and Distribution share one read, because approving a
transcript is exactly what creates the record Distribution shows; two reads
could disagree about one transcript, and a publish that a stale list says
never happened is the worst thing that tab could say. The connection read
joins them because Settings connects what Recordings needs. Race-safe per
#555: a generation on reads, an in-flight guard on writes, and the unknown
versus disconnected distinction the page already documented — a check that
could not run is not a check that ran and said no.
fmtDate was two functions. Each provider tab carried its own copy,
identical except that only the Podcast one guarded Number.isNaN, so the
same malformed timestamp rendered as '' on one tab and "Invalid Date" on the
other. The guarding one won.
Plaud routing to ContentForge and transcript publishing are unchanged; the 24 existing end-to-end assertions are unchanged except where the split moved what they read, and 33 more cover the tab resolver, the helpers, the new navigation and Distribution.
- Complexity Delta: report the complexity a pull request adds, not the
complexity already here (#624). A new
Complexity Deltaworkflow andscripts/complexity/.
Why it is a delta gate. Measured at the head of main on 2026-09-16,
lizard finds 479 functions over CCN 8 — 322 over 10, 117 over 15, 43 over 20,
9 over 40, with 253 of them in frontend/ and 198 in functions/. A
whole-tree run at CCN 8 is therefore red 479 times over on its first pull
request and on every one after it, INCLUDING pull requests that remove
complexity. A check that cannot go green is a check nobody reads, and this
repository already has one of those in monitor-unresolved-secrets.yml. So
the question asked is the one with a green answer available every time — did
THIS pull request make it worse — and the 479 are left to be worked down
separately.
Every function in a changed file is compared against itself at the merge base. A function that is new and over the threshold, or over it and higher than it was, is reported; one that is over it but no higher is carried without complaint; one that got simpler is reported as a win. Carrying is the point — editing one line of a CCN 30 function must not be refused, or the backlog becomes unfixable by the thing asking for it to be fixed. A total-CCN line covers what per-function verdicts miss, since twenty new functions at CCN 5 trip no verdict and are still 100 CCN of new branching.
Functions are matched by (path, name), never by line number, and one that
is new at its path is first paired with a same-named function that left
elsewhere in the same diff. That pairing is not polish: lizard's JavaScript
tokenizer drifts with file context, and getLiveUrl measures 7 inside the
1,551-line SocialHubPage.jsx against 10 as the byte-identical body in
socialView.js — the only textual difference being the export keyword.
Without it, splitting a large file reports every function in the new files as
newly added complexity, taxing the one refactor this repository most needs to
stay cheap. Anonymous functions never pair, because lizard names every arrow
function (anonymous) and matching on that pairs the 40th callback in one
file with the 3rd in another.
Report-only by default, so findings arrive as annotations on the changed
lines and the check stays green; COMPLEXITY_ENFORCE arms it and
COMPLEXITY_THRESHOLD moves it. The trigger is not path-filtered, for
the reason ADR 0026 records.
Two notes for whoever reads this next. lizard . -C 8 -W does not do what it
reads like: -W is --whitelist and takes a filename, so that command exits
2 from argparse before measuring anything — lowercase -w prints warnings,
and lizard already exits 1 on any. And the gate is held to its own standards:
render came back at CCN 20 and compare at 14 on the first draft, and then
Qlty put the single module at file-complexity 72 — the gate's own file being
the thing the gate exists to prevent. It is now four modules (model,
measure, compare, report) behind the CLI, none over CCN 8. Nineteen unit
tests cover the comparison, now without needing lizard installed to import it,
and run as a workflow step before the gate judges anything.
CodeQL contributed the other half. It read the field named ccn as a credit
card number — its "private" heuristic covers card numbers, and the message
interpolating two of them produced two "clear-text logging of sensitive
information" alerts on one line. The field is now complexity, which is the
clearer name regardless, and the flag is --threshold. Its third finding was
real: a print in an except ImportError at module scope runs during import,
so the missing-dependency report moved into main.
.py joined the wiki-pointer guard's TEXT list in the same change. That
guard fails when a tracked extension carrying five or more files is neither
scanned nor explicitly exempt, and scripts/complexity/ took the repository
from four Python files to six — the test doing exactly the job its comment
claims, making the next language added a decision rather than an oversight.
Python here is prose: three of those six open with a docstring telling an
operator what to do, so a pointer at the retired Wiki inside one would be as
dead an instruction as a pointer in a runbook.
- Cloud pricing comparison, Phase 3 backend: price history, a newsletter section and "Explain this number" (#613). Three parts on the same refresh.
- Price history and change detection (
lib/cloud-tools/history.js). After each region's cache document therefresh-tool-pricingjob writes onetool_price_historysnapshot per region per UTC day (history:<region>:<YYYY-MM-DD>, a map of<serviceId>:<provider>cells withpricePerUnit, unit, sku, currency, source: live|baseline) and diffs it against the newest snapshot at least 7 and at least 30 days old — found by point reads of day ids N..N+6, never a query — intoprice-changes:<region>intool_service_cache:{ region, asOf, windows: { '7d': { since, sampleDay, items }, '30d': {…} }, sampleDays }, each item{ serviceId, label, provider, unit, sku, from, to, deltaPct }, live-against-live only (a live/baseline flip, a unit change and an unchanged price are not changes), largest move first. A history failure is logged and recorded in the run summary; the cache document stands.GET public/cloud-tools/price-changes?region=serves the document as a four-field projection; before any comparison exists it answers empty windows andsampleDays: 0, cached a minute. The container is generated from the manifest (regenerate,/id, 400-day TTL) and classified regenerable for the export. - Newsletter section "Cloud price changes" (id
cloud-price-changes,lib/newsletter/sections.js): the 7-day window ofprice-changes:us-east-1— region fixed, an email has no picker — as "AWS · Virtual machines: $0.192 → $0.201 per hour (+4.7%)" linking to/tools/comparison?region=us-east-1, rendered through the same item shape and renderer as its siblings. With no moves the section is omitted from the issue entirely; there is no "no changes" filler. On by default, as the registry rule (#557) makes every new section; the Newsletter Hub's Content form lists it to turn off or cap. - "Explain this number":
POST public/cloud-tools/explain(lib/cloud-tools/explain/,functions/cloud-tools-http.js), anonymous, takes the priced scenario the page already shows ({ region, scenarioId, scenarioLabel, extras, egressGb, results: [{ provider, total, base, segments, unavailable }] }, 8 KB, validated field by field, unknown keys refused) and answers{ explanation: { text, model, generatedAt, cached } }— two paragraphs from the AI router's default chain on which provider is cheapest and by how much, what the extras add, and one thing that could flip it, from the numbers given and nothing else. Bounded four ways: cached by a hash of the canonical body for 7 days (a shared link costs one model call), 5 per hour per Cloudflare-verified client on the submission counter (429), 200 per UTC day across everyone through a compare-and-increment counter (503 "paused for today"), and 503 "not available" before any counter moves when the router cannot serve it — no key, or the new Pricing explanations toggle on AI Engine → AI features switched off, which is the owner's off switch for the one anonymous AI call. Anything URL-shaped is stripped from the model's text before it is stored. - Cloud pricing comparison, Phase 3 frontend: price changes, explain,
second region, article embeds (#613). Four additions to
/tools/comparisonand one to articles, against the two Phase 3 routes the backend PR adds:GET public/cloud-tools/price-changesandPOST public/cloud-tools/explain. Until that PR is deployed the card and the button show the server's error rather than a number. - Price changes (
pages/tools/PriceChanges.jsx), under the table: what moved in the region's list prices over the last 7 or 30 days, one row per move — "AWS · Virtual machine $0.192 → $0.201 /hour ↑ +4.7%", rises and falls coloured and signed — with "No changes in the last 7 days" and "No history yet" as their own states and an as-of line. Follows?region=. - Explain this comparison (
scenario/ExplainButton.jsx): posts the computed comparison (provider, total, base, segments, unavailable — no lines, so it stays under the server's 8 KB cap) and shows the two paragraphs under "Generated by AI from the numbers above — check the sources before relying on it", with the model, the time and a note when the answer was cached. Only on a click, never on load; disabled while any provider is unavailable; 429 reads "try again in a while", 503 shows the server's sentence, anything else offers Retry. The answer is keyed to the numbers it explained and goes away when they change. - Compare with (
scenario/CompareRegion.jsx): a second-region select on the results, state in?compare=, that fetches that region once and puts "US West: $782.10 (+10%)" under each provider's total and "Cheapest in US West: Google Cloud" under the list; loading and failure inline. An id the API did not list, or the region already shown, is ignored and dropped from the URL on the next change; a previous region's answer is never shown as the new one's.lib/pricingScenarios/compare.jsholds the arithmetic;share.jscarriescompare=with the rest of the scenario. - Article embeds (
components/content/PricingScenarioEmbed.jsx): a fenced block with languagepricing-scenariowhose body is a scenario query string renders the scenario card read-only inside a post, with an "Open in the comparison tool" link. Wired through thecodeoverride every markdown renderer shares (components/shared/CodeBlock.jsx), so blog posts, RichTextBody and the ContentForge preview all get it; the card is aReact.lazychunk a post without the fence never loads, and a post with it pre-renders and hydrates cleanly (test withrenderToString→hydrateRoot). Documented indocs/content/blog-template.md. - Cloud pricing comparison, Phase 2: scenarios and extras (#613). A
"Price a scenario" card above the Phase 1 table on
/tools/comparison: pick a shape (Static site + API, Three-tier web app, Event-driven backend, Data platform, Kubernetes platform), edit its quantities, and see a monthly and yearly bill per provider as one stacked bar each — the base cost and every extra as its own segment, the cheapest highlighted and the others marked "+N% vs cheapest". Priced from the same cached list prices as the table, in the same region. - Extras from a pull-down: backup, zone redundancy, a DR level (pilot light, warm standby, active-active — one at a time) and a 1- or 3-year commitment (drawn as a dashed reduction at the end of the bar), plus an egress select (100 GB to 20 TB). Every extra is priced off the same eight meters, so nothing on the page comes from a number the price list did not supply.
- The maths is visible: "Show breakdown" lists every line as quantity ×
factor × unit price, and "How this is calculated" renders each extra's
rule and the
ASSUMPTIONStable — every multiplier per provider, with a link to the provider page it was read from (frontend/src/lib/pricingScenarios/). - Never silently zero: a provider with no price for a service the
scenario uses shows "unavailable:
" instead of a total, and a total with any catalogue line carries the "catalogue price" badge. - Shareable URL: the scenario, extras, egress and edited quantities
live in the query string beside
?region=, and Copy link copies it (or shows it to copy by hand when the clipboard refuses). - Cloud pricing comparison, Phase 1 (#613).
/tools/comparison— "Pricing Comparison" in the Tools menu, formerly "Pillar Comparison" and a Coming Soon page — is a real page: one row per service shape (VM, serverless, object storage, relational and NoSQL databases, Kubernetes, messaging, CDN) with a list price per provider on AWS, Azure and Google Cloud, read from the daily server-side cache throughGET public/cloud-tools/pricing?region=. - The region is in the URL (
?region=), so a comparison is a link. - Each cell says what it is: the price to four significant figures, the provider's SKU beneath it, a "catalogue price" badge when the provider's price list could not be read and the site's own figure stands in, and "unavailable" when there is neither. The cheapest cell on a row is highlighted, and every cell links to the provider's pricing page.
- An as-of line says when the prices were refreshed, or that the cache is stale, or that it has never been filled.
- Integrations Hub → Cloud → Cloud pricing cache: a card whose test
reads the cache and reports its age and its live / catalogue / unavailable
counts, and whose Refresh now enqueues the
refresh-tool-pricingjob and re-reads once it finishes. -
Scenarios, monthly estimates and the remaining extras are Phase 2.
-
Cloud Tools pricing: the timer, the job and the public read (#613, backend half). The ported live-pricing library in
functions/src/lib/cloud-tools/had no caller; now it has three. refresh-tool-pricingjob (functions/src/functions/cloud-tools-jobs.js,lib/cloud-tools/refresh.js): for each of three region options (us-east-1,us-west-2,westeurope—pricing/regions.js, asserted againstPROVIDER_REGION_MATRIX) fetches all eight catalog services from AWS, Azure and GCP one service at a time and upserts onetool_service_cachedocument per region:{ id: 'pricing:<region>', region, refreshedAt, ttlMinutes: 1440, services[], counts { live, baseline, unavailable } }. A region failing is logged and the others continue. Editor-enqueueable ({ type: 'refresh-tool-pricing', payload: {} }, optionalpayload.regions), ten-minute budget, failure to Telegram like the export job.refreshToolServiceCachetimer, daily 02:00 UTC, behindFEATURE_FLAG_REFRESH_TOOL_SERVICE_CACHEand the schedulers master switch; enqueues the job rather than running 24 lookups inside a timer. Catalogued ininfra/functionapp.tfand allowed by theenabled_timersvalidation; the twentieth catalogued timer and the twenty-first registration, and every prose count was moved with it. The once-per- process "disabled — skipping" Warning is nowlib/timers/flag-gate.js, shared byschedulers.jsand this timer.GET /api/public/cloud-tools/pricing?region=(lib/cloud-tools/public-pricing.js): one point read of the region's document,Cache-Control: public, max-age=900, freshness reported throughcacheFreshnessand never repaired on the request path. No document yet is a 200 withrefreshedAt: null,stale: trueand every cell unavailable, so the page renders "not refreshed yet" rather than an error. Unknown region is 400.-
Owner decision 2026-09-15: the two
KNOWN_UNIT_MISMATCHESservices (compute-serverless,database-nosql) get no baseline fallback —fallbackBaselineForanswers null for them, so a live miss is an absent row counted as unavailable, never a number in the wrong unit. Recorded in thebaseline.jsheader and pinned by a test. -
Linkie posts can carry an image (#501). On
/admin/linkie, Add a Post has an optional image row. The image can be uploaded from the computer into the publiccoverscontainer (PNG, JPEG, WebP, GIF or AVIF, up to 15 MB), or chosen from the image gallery, and shows a preview with a Remove button. - Push Published Content sends the article's cover image.
- Current posts show their thumbnail.
- The field is
thumbnail_url, confirmed from a real Linkie post response. It is sent only when an image was chosen, so posts without one are unchanged. -
Shared upload code: file reading and upload live in
lib/imageUpload.js, which the Image Gallery page now shares. -
Social Hub: tabs by duty, with an Accounts tab and a Published calendar (#575).
/admin/socialhad four tabs already, but the page was one 1,551-line component holding the Publer client, the view helpers and every tab's JSX. It is now five tabs at the Newsletter Hub standard — Compose, Queue, Published, Accounts, Settings — and the page is 93 lines. The panels, the Publer client, the shared pieces and the pure helpers live incomponents/admin/social.
Accounts is its own tab, and Settings answers one question. The connected profiles were a card inside the Connection Settings tab, below the credential tile; they are the list an operator checks before composing, not something set once, so they have a tab. What is left on Settings is whether the proxy can use its credentials — which is all the browser can honestly say, since the key lives in Key Vault and the proxy never sends it back (FINDING-04) — plus where each of the settings this hub does not own is actually set.
Published groups by the day a post went out and reports per-account
outcomes. A flat list of posts is not a history. Posts Publer returns with
no timestamp are kept in an Undated group rather than dropped: a post with
no stamp still published, and losing it from the record would be the worse
error. postResults reports only the state and error Publer actually sent —
an account it said nothing about is shown with no state rather than as a
success, which is the #463 mistake (a job whose every account failed reported
as a success) one layer up.
Each tab reads its own data, so one failure cannot blank another. The
accounts read and the live-pages read are separate hooks that Compose,
Published, Accounts and Settings call for themselves; a missing Publer key
leaves the content picker usable, and a failed content read leaves the account
list visible. There is nothing here for two tabs to disagree about — unlike
the Listen & Learn Hub, no tab writes state another tab shows — so this is the
right side of that trade. useRecentContent carries a generation, so a
superseded read paints nothing, and a failed read empties the list rather than
leaving rows beside an error saying they could not be read (#555). The Queue's
deletes are guarded in flight: a second click while the first is unanswered is
ignored rather than sent twice.
Deep links are ?tab=, and now they are validated. The page read
searchParams.get('tab') || 'compose' and tested it against four literals, so
an unknown id rendered the header with no panel under it at all. resolveTab
reads own properties only, so ?tab=constructor cannot reach
Object.prototype and hand a function to the panel lookup. connection —
the word on the old tab bar, which read "Connection Settings" over an id of
settings — lands on Settings, and profiles, calendar and scheduled
land where their content went. All four ids the page shipped with still
resolve to themselves.
Composing and scheduling are unchanged. The two endpoints, the delete
form, the job poll and the envelope reading are the #463 and #397 code moved
verbatim into social/publerApi.js; their tests moved with them and every
assertion is unchanged. The tab bar is now the shared HubTabs, so the Social
Hub gets the role="tablist" contract and arrow-key navigation the other hubs
have.
- Listen & Learn Hub: tabs by duty (#574).
/admin/listen-and-learnwas one scroll — the generate form, the grounding panel, the list of sets and the chosen set's episodes, stacked — and is now a tab per duty at the Newsletter Hub standard: Generate, Review, Published, Settings. The page went from 622 lines to 99; the panels, the episode card, the voice field and the view helpers live incomponents/admin/listen-and-learn.
Review and Published are one component and two configurations of it. The
first draft was two files differing in a filter, an empty-state sentence and
a doc comment, and identical for thirty-five lines either side — the
duplication #588 counts, and two places to change the approve button. They
are now SetEpisodesTab with a keep predicate. Failed episodes sit on
Review rather than Published: they are work needing a decision, and the card
already explains the failure.
Three Qlty structure findings blocked the first push and are fixed at the
source rather than silenced. useListenAndLearn came back at complexity 34
with 10 returns, so the reads and writes are now module-level functions over
one state bag, each with a single exit — the shape useCertifications
documents for exactly this reason, and the reason the hook stays inside the
budget as it grows. queuedMessage carried 6 returns; its two
no-provider reasons moved into a frozen table read through
hasOwnProperty, which drops it to 4 and means a reason of constructor
cannot reach a function. Every assertion on both is unchanged.
The reads are race-safe the way #555 hardened the Newsletter Hub. Every
episode read goes through one generation counter, so opening set B while set
A is still loading paints B — an A that answers late is dropped rather than
shown under B's heading. That is a bug a bare cancelled flag does not
catch, because both reads are on the same mount. A failed read empties the
list rather than leaving the previous set's episodes beside an error saying
they could not be read, and approvals are guarded per episode so a second
click while the first is unanswered is ignored rather than sent twice.
A run keeps reporting while the operator is on another tab, because
generating and progress live in the hook rather than in the Generate tab
— a run takes several minutes, and leaving the tab must not lose the line
that says what it cost.
The stored voice default is derived, not copied into form state. null
means "not chosen yet", so the default appears when the settings load; ''
means the operator chose "Stored default" deliberately. Both send no model,
and keeping them distinct is what lets a late settings response fill an
untouched field without overriding a choice already made. The previous shape
wrote the default back into state from an effect, which is a second source of
truth for one value and is what react-hooks/set-state-in-effect objects to.
Deep links are ?tab=; the old section words (sets, episodes, voice,
grounding) and anything unknown land where their content went. resolveTab
reads own properties only, so ?tab=constructor cannot reach
Object.prototype and hand a function to the panel lookup — pinned by a test,
as it is on the Certifications Hub.
Settings links to Platform settings for the voice default rather than offering a second editor over the same document, and records why certification episodes are grounded on the official study guide alone: one outdated video demonstrating a retired portal flow would otherwise be laundered into an episode that sounds authoritative, and exam prep is exactly where confidently wrong is worse than absent.
- Certifications Hub: tabs by duty (#572).
/admin/certificationsmoves from one page (1,107 lines) to five tabs on the sharedHubTabsbar, deep-linked with?tab=: - Catalog: stats, search, issuer filter and the card grid.
- Featured: featured certifications in display order.
- Renewals: expired certifications and those due within 180 days, soonest first.
- Publishing: the Publish snapshot button, when the public snapshot was last published, and what changed since.
- Settings: image rules, verification sources and hidden items.
Old ids such as expiring, hidden and publish land on the tab that now
holds them. The list loads once at page level with a generation guard, and a
read that raced a write is issued again. Writes have a per-certification
in-flight guard, and the editor's save and upload have their own. The
Publishing tab reads the public snapshot on its own, so a failure in either
read stays on its tab. PublishSnapshotButton gains an optional
onPublished callback, and publicApi.js gains fetchPublicSnapshot.
- Speaking Events Hub: tabs by duty (#573).
/admin/speaking-eventsis now five tabs on the sharedHubTabsbar, deep-linked with?tab=: - Upcoming: future sessions, soonest first.
- Past: delivered sessions, newest first, with slide and event links.
- Sources: when Sessionize was last read, a preview of what a sync would create or fill in, Sync from Sessionize, and manual entries.
- Publishing: the public snapshot and what a publish would write now.
- Settings: the Sessionize speaker ID, read-only, with a link to the Integrations card that stays its only editor.
Old ids such as events, sync and publish land on the tab that now holds
them. Each read is generation-guarded, and a failed refresh clears its rows.
Save, delete and sync each have an in-flight guard, and the session lists
wait for both reads so Enrich cannot create a duplicate record. Behaviour
changes: the store is re-read after a partly failed sync, and event links
render only for http(s) URLs.
- Health Hub: Code and Security tab, and a Qlty card on Integrations
(#569). A new Code and Security tab reads
GET /api/cms/code-quality(from #590) the first time it is opened, not on page load, and shows: - Qlty's grade tiles: maintainability, security, coverage, duplication and technical debt;
- open issues by level, and the security total with its split;
- categories, top rules and top files, linked to Qlty;
- an as-of time, and a warning when the summary was truncated.
Qlty not configured is a notice linking to the Integrations Keys tab, and a
refusal is a retryable error. The loader uses the Health Hub's generation
guard, so a slower read never overwrites a newer one and a failed refresh
clears the numbers. The copyable report gains a Code and Security section
once the tab has loaded. The Integrations Hub gains a Code quality group with
a Qlty card (QLTY-API-TOKEN) whose test calls the qlty connection probe
and names the account the token belongs to. ?tab=qlty, quality,
security and code-quality open the tab.
- Health Hub: tabs by duty instead of one long scroll (#569).
/admin/healthnow has four tabs on the sharedHubTabsbar, deep-linked with?tab=: - Overview: the at-a-glance strip, readiness, publishing and operational signals.
- Alerts: workflow alerts with their filter and actions, and their own outcome line. Before, it was written onto the smoke-test card.
- Checks: smoke tests, token claims, admin registry, the Labs probes and Re-run identity checks.
- Report: the Markdown report and Copy report.
Old ids such as diagnostics, signals and verify land on the tab that
now holds that content. The header and tab bar always render: a failed
ops-health read is an error on Overview and Alerts, with Try again, while
Checks and Report carry on. The strip's observed badges say UNKNOWN rather
than zeros until the snapshot arrives. Every snapshot read (first load,
refresh after a smoke test or alert action, Try again) goes through
health/useOpsSnapshot.js, which has a generation guard. A slow read
cannot overwrite a newer one, and a failed read clears the numbers instead
of leaving them beside the error. Shared state stays on the page, so
switching tabs never refetches or reruns a probe. The Code and Security tab
follows in a separate PR.
-
Code and Security summary API for the Health Hub (#569).
GET /api/cms/code-quality(editor) reads this repository's Qlty project server-side with the newQLTY-API-TOKENsecret (app settingQLTY_API_TOKEN): project metrics, and every open issue paged 100 at a time. It answers condensed totals by level, category and security, the top ten rules and files, and Qlty's own grades and coverage, cached for ten minutes. No finding message, snippet or fingerprint leaves the server, and log lines carry only the route, status and invocation. Checked live on 2026-09-14: it counts 1,353 open issues and 48 security issues, equal to Qlty's Total Issues and Security Issues metrics. The token gets aCode qualitysection in the secret catalogue, so it can be seeded from the Integrations Keys tab, and aqltyconnection probe (GET https://api.qlty.sh/user). The Health Hub tab that shows the summary follows in a separate PR. -
Integrations Hub: tabs by duty instead of one long page (#570).
/admin/integrationsis now four tabs at the Newsletter Hub standard, split intofrontend/src/components/admin/integrations/. Overview is one grid of every service, broken and not-configured first, with Test all: each service's existing test, run one at a time, with YouTube left out because its test spends daily quota (its tile says so), and the time each ran this session. Services shows one group at a time (?group=); each card keeps its description, docs link and test, and lists the keys it uses by name and light only. Keys holds every catalogue credential under its group, the services that use each one, the unchanged paste and generate actions, and "Other credentials"; one write runs at a time, and no value is fetched or rendered. Identity is the Entra configuration panel as it was (#519). Tabs deep-link with?tab=, moved or unknown ids land where their content went (Overview by default), and/admin/connectionsand/admin/api-keysnow redirect to Overview and Keys. Each tab reads its own data with its own loading and error states, reloads are generation-guarded, and a failed refresh clears what it replaced. No API, probe or seeded value changed. There is no Activity tab: key writes keep only the latest writer per key and no admin route readsadmin_audit_logs, so that history needs a backend first (noted on #570). -
Platform Settings Hub: one tab per concern, plus a change history (#571).
/admin/platformis now four tabs at the Newsletter Hub's level of separation, deep-linked with?tab=: Content defaults (default covers, and a link card to Newsletter Hub → Settings rather than a second form over the newsletter document), Social automation (autoposting, the only tab that asks Publer for accounts), Audio (podcast feeds and the Listen & Learn voice) and Change history. Each tab mounts only while open and loads its own settings with its own loading, error and Retry states, so a failure in one never blanks another; an unknown?tab=lands on Content defaults. Every save sends the same body as before.useSettinggains the #555 race guards: a generation number on loads, an in-flight ref on saves (a double submit sends one PUT), and a failed load clears the working copy and hides the form. Change history reads the newGET /api/cms/platform-settings/history?setting=&limit=&after=(editor):platform_setting_updatedrows fromadmin_audit_logs, newest first, TOP 1..100 (default 50), with the setting validated against the spec registry and every refusal a 400 before Cosmos. Each entry is{ id, at, actor, setting, summary }, where the actor is the display name or else the Entra object id and never the email the row also holds, and paging is by timestamp throughnextAfter. New audit rows recorduserNamefrom the token'snameclaim so the table can say who. The tab shows time, who, setting andkey: valuesummary chips, with a setting filter, Refresh and Load more. -
Qlty set up in the repository, with test coverage uploaded from CI (#568).
.qlty/qlty.tomlis committed, so a localqlty check --alland Qlty Cloud run the same plugins: the linters that work here (actionlint, editorconfig-checker, eslint forfrontend/andscripts/, prettier, ruff, radarlint-python, ripgrep, tflint) and the security scanners (bandit, checkov, gitleaks, osv-scanner, radarlint-iac, trivy, trufflehog, zizmor). ESLint stopped failing withCannot find package 'globals': the init-writtenpackage_filtersdroppedglobals(andreact, which the frontend config reads), so each package's own ESLint config now installs complete. Generated and vendored paths are excluded (frontend/data/**, public JSON and fonts,dist,coverage,site,.agentic). No security finding is silenced inqlty.toml; triage uses each scanner's own ignore file, recorded indocs/security/scanner-triage.md(#567).frontend/andfunctions/gainnpm run test:coverage(V8 coverage,coverage/lcov.infowith repository-relative paths;functions/adds@vitest/coverage-v84.1.11);npm testand the required CI jobs are unchanged. The new, non-requiredcoverage.ymlworkflow runs both on pull requests and pushes tomainand uploads them in oneqltysh/qlty-action/coveragestep pinned to v2.3.0's commit. The upload authenticates with GitHub OIDC, so no Qlty token is stored; pull requests from forks, which cannot mint one, skip the upload with a notice. -
Use a Resend template as the newsletter's design (#557). Newsletter settings on the Mailing List Settings tab gain a Template block: keep the built-in design (the default, and what existing settings read as) or pick one of your published Resend templates, listed from
GET /api/cms/mailing-list/templates; unpublished templates show disabled, and when the list cannot be loaded the saved choice is kept and the reason is shown. Resend broadcasts take no template id and template variables cannot loop over articles, so the site does the merge (lib/newsletter/template-layout.js): the week's body, rendered exactly as the built-in design renders it, goes at{{{NEWSLETTER_BODY}}}(required, exactly once);{{{NEWSLETTER_SUBJECT}}},{{{NEWSLETTER_PREHEADER}}}and{{{NEWSLETTER_PERIOD}}}are filled with escaped text;{{{UNSUBSCRIBE_URL}}}is rewritten to{{{RESEND_UNSUBSCRIBE_URL}}}; and the postal address and unsubscribe link are appended when the template lacks them. The template is fetched with a five-minute in-process cache per id, dropped when a different template is saved. The preview and the test send use the template, or the built-in design with a visibletemplateProblemwarning when it cannot be fetched or used; approval is refused with409 TEMPLATE_UNUSABLEbefore the issue is claimed, so a newsletter never goes out in a design other than the one chosen. The plain-text part is unchanged, and the built-in HTML is pinned byte-for-byte byrender.test.js. -
Choose where the newsletter signup box appears, and its wording (#557). Newsletter settings on the Mailing List Settings tab gain a Signup form block: show the box in the footer on every page, at the end of blog posts, both, or nowhere, and set its heading (up to 80 characters) and blurb (up to 240), with character counts and a live preview that uses the site's own box and cannot subscribe anyone. It saves with the rest of the settings. The site reads the choice from a new anonymous
GET /api/public/newsletter/signup-config, which answers{ placement, heading, blurb }and nothing else from the settings document, is cached for five minutes, and answers the defaults with 200 when the settings cannot be read. The footer and the blog post share one request per page load, render the wording as plain text, and show the defaults (both places, the original wording) while it loads, if it fails, and in the pre-rendered HTML, so a site whose settings were never saved looks exactly as it did before. The public subscribe and confirm routes are unchanged. -
Choose what each newsletter issue is built from (#557). Newsletter settings on the Mailing List Settings tab gain a Content block: tick which sections go in, put them in order with up and down buttons, set the most items each may show (1 to 20), how many days back to look (1 to 31), whether the AI intro is written and in which tone (professional, friendly, short and direct, enthusiastic). It saves with the rest of the settings and the form shows what the server stored. The issue builder reads these for both the Build this week's issue button and the Monday automatic build, neither of which passes a fixed seven days any more; an explicit
dayson the job still wins, clamped. Turning the intro off builds with no AI call and no intro error; the regenerate-intro button uses the saved tone. Settings saved before this read as the old behaviour (every section, 12 items each, seven days, a professional intro), with no migration. Nothing here sends email. - Resend domains, recent emails and API logs on the Mailing List Settings tab (#504). The page half of the Resend domains, logs and emails API (#553), frontend only. Three cards below the connection check, each loading when the tab opens and failing on its own (503 says Resend is not configured, 429 says how long to wait), so none can hold up the newsletter settings. Sending domains lists each domain with its status and region; opening one loads its DNS records (type, host, value with a Copy button, priority, TTL, per-record status) with a note to add them at Cloudflare, a Verify button that says the check runs in the background and offers Refresh, and open and click tracking switches that PATCH only the switch that changed, with a warning that open tracking needs the tracking subdomain CNAME and is recommended for broadcasts only. An Add domain form takes a name (checked as a hostname before sending; the server decides) and a region, defaulting to us-east-1, and opens the new domain on its records. There is no delete; a note links to resend.com/domains for that. Recent emails shows the server-masked recipient, subject, time and last event, and API logs the time, method, endpoint and a status coloured by 2xx, 4xx or 5xx, both paging with Load more. Opening a log shows its redacted request and response bodies as pretty-printed text, never as HTML. The only writes are domain add, verify and tracking, all publisher-only on the server; a refusal is shown in the server's words.
-
An Audience tab and per-issue metrics on the Mailing List page (#504). The page half of the Resend metrics and audience API. A new Audience tab, between Published and Settings, shows the Newsletter segment's subscribed, unsubscribed and total counts (with when they were counted, and a note when they are a lower bound), then the contacts fifty at a time with Load more and a debounced email search that says it searches the loaded page. Each row can be unsubscribed, resubscribed or removed; those are the only writes, they are publisher-only on the server, a refusal shows the server's message, and Remove asks first because it deletes the contact from Resend. Contacts are addressed by Resend id, never by email. On Published, a sent issue now shows delivered, unique opens and clicks with their rates, bounces, unsubscribes and complaints above its email, its top ten links, and a collapsible list of who opened, clicked or bounced; a scheduled issue says metrics come after it sends. The month header adds that month's delivered count and open rate, loaded after the calendar and left out if Resend cannot answer. A 503 says Resend is not configured and a 429 says how long to wait, which
lib/api.jsnow carries on the thrown error asretryAfterSeconds. -
Resend metrics, audience, domains, logs and templates for the Mailing List page, in the API (#504). The owner wants Resend's numbers and settings on the site rather than in Resend's dashboard; this is the API half, and the Metrics, Audience and Settings → Resend pages follow. New routes under
/api/cms/mailing-list/:metrics(by broadcast, or by newsletter issue, defaulting to the last 30 days), a broadcast'sclicked-linksandrecipients, one page of the Newsletteraudienceplus a cachedaudience/summaryof subscribed and unsubscribed counts,domainswith their DNS records,logs(bodies only on the detail, with credentials and email addresses redacted), recentemailswith masked recipients, andtemplates. Reads need editor. No route sends email. The ones that write to Resend — unsubscribing or deleting a contact, and adding, verifying or changing tracking on a domain — are publisher-only; deleting a domain is deliberately not offered. Every id, cursor, date, address and limit is validated before Resend is called, a Resend refusal comes back as a 502 carrying Resend's reason (a rate limit as a 429 with the wait), and log lines carry no addresses. -
Faster newsletter editing on the Mailing List page (#504). The page half of the editing API change further down. A draft on the Newsletter or Drafts tab now opens with the subject, a Suggest subjects button whose three to five AI suggestions fill the subject when clicked, a Preview text field with a 150-character counter, and the note. The action bar holds Save changes, Reset (discards local edits), Regenerate intro, Send test to me (one
[TEST]copy to the reply-to in settings, then a 60-second countdown) and Keep in Drafts or the approval box. Below it, a section editor sits beside the email preview on wide screens: sections collapse, and sections and items move up and down or are removed, with the last item protected as the server requires. Section edits count as unsaved, so Keep and Approve stay disabled until they are saved, and Regenerate intro and the test send wait for a save too, since both act on the stored issue. After a test send the page holds the issue's new etag, so the next save is not refused as stale. Frontend only; nothing on the page reaches subscribers except the unchanged Approve.IssueDetail,SectionEditor,ApprovalBoxandissueFormatare split out ofNewsletterIssues.jsx. -
Faster newsletter editing in the API: preview text, section trimming, AI intro and subject help, and a test send (#504). The owner found editing a weekly issue "still very stiff"; this is the API half, the page follows.
PATCH /api/cms/newsletters/{id}now also takespreheader(the inbox preview text, at most 150 characters, rendered as the standard hidden block at the top of the email) andsections, which may only remove or reorder items and sections: every item is matched to a stored one by section and URL, the stored items are written back, and anything added, moved or edited is a 400.POST .../introregenerates a draft's intro with the builder's own drafter and instruction (a failure stores nothing, 502);POST .../subjectsreturns three to five subject-line suggestions and writes nothing.POST .../test(publisher) emails one[TEST]copy to the reply-to address in Newsletter settings, never an address from the request, at most once a minute per issue. Nothing here can reach subscribers: the test uses Resend's single-email endpoint, which is why it works while sending is switched off. Approve is unchanged apart from now rendering the preheader. Also correctstimer-catalogue-sync.test.js, which placedlocal.timer_catalogueininfra/main.tf; it is ininfra/functionapp.tf. -
The weekly newsletter builds itself into Drafts every Monday (#504). A new timer,
buildWeeklyNewsletter, runs Monday 13:00 UTC (08:00 CDT / 07:00 CST), builds the last seven days into an issue saved straight to the Drafts tab, and sends a Telegram message with the subject, the item count and a link to/admin/mailing-list?tab=drafts. A week with nothing new, or a day whose issue is already kept in Drafts or approved, writes nothing and says so; a missing AI intro is called out. It never sends: approval stays a manual press on the Drafts tab. It ships disarmed behindFEATURE_FLAG_BUILD_WEEKLY_NEWSLETTER— addBUILD_WEEKLY_NEWSLETTERtoenabled_timersin the HCP Terraform workspace to arm it. The issue builder gained akeepoption for this, and thebuild-newsletter-issuejob now passes onlydaysthrough from its payload. -
A Settings tab on the Mailing List page (#504). Newsletter settings no longer sit under the issues, and the Connection tab is gone: both are on Settings, the last tab. A bookmark to the old Connection tab opens Settings.
-
Newsletter, Drafts and Published tabs on the Mailing List page (#504). The owner's layout of 2026-09-13. Newsletter builds this week's issue for review; each issue card has a red X that deletes it at once, and Keep in Drafts moves an issue worth sending to Drafts, which is the only place an issue is approved. Published is a month calendar of what was sent (filled count) or scheduled (outlined count) on each local day; choosing a day shows that day's email in a sandboxed preview. Reject is now only Clear stuck send, for an issue left mid-send.
-
Delete, save to Drafts, and a month of published issues in the newsletter API (#504). The API half of the Mailing List page's Review, Drafts and Published tabs.
DELETE /api/cms/newsletters/{id}deletes a draft or rejected issue (never one that was approved); it is a conditional write todeleted, so it cannot race an approval, and a deleted issue reads as not found and is rebuilt from scratch.POST /api/cms/newsletters/{id}/savekeeps a draft, and the builder never rebuilds over a saved one.GET /api/cms/newsletters?month=YYYY-MMlists what was sent or scheduled in a month for the calendar. List rows now carryetagandsavedAt. -
Approve the weekly newsletter from the Mailing List page (#504, ADR 0030 §2a).
POST /api/cms/newsletters/{id}/approveroutes the approval logic added just before this, and the Newsletter tab gains Approve and schedule, then Yes, send it. It sends the version on screen, will not approve unsaved edits, re-reads the issue after any answer that is not a clean success, and offers only reject for an issue stuck insending. Whilenewsletter_sending_enabledisfalsein Terraform the page says sending is switched off and shows no Approve button, and the route refuses with 503. -
The approval logic for the weekly newsletter (#504, ADR 0030 §2a).
approveinlib/newsletter/admin-handlers.jsandcreateBroadcastin the Resend client. It landed before its route; the route and the Approve button are the entry above. Approval stays refused untilnewsletter_sending_enabledistruein Terraform.
At most once, and only as the approver saw it. Approval requires the
issue's etag, so an issue edited after the approver opened it is refused. The
send slot and the email are worked out first, then the issue is claimed
draft → sending with an ETag-conditional write before Resend is called, so
two approvals at once create one broadcast. The outcome is recorded only while
the issue is still that claim: a reject that lands mid-send stands. A refused
broadcast returns the issue to draft with Resend's reason. No answer from
Resend at all leaves sending, because a broadcast may exist. Reject may now
clear an issue in sending. Approval logs carry the invocation id only.
-
A Terraform switch for newsletter sending, off by default (#504, ADR 0030 §2a). New variable
newsletter_sending_enabled(defaultfalse) sets the app settingNEWSLETTER_SENDING_ENABLED. The issue API reports it assendingEnabled, and the Mailing List page says when sending is switched off. Nothing sends yet: approval arrives next, and will be refused while this is off, so turning sending on is the owner's confirmed Terraform run. -
Build and review the weekly newsletter from the Mailing List page (#504, ADR 0030 §2a). Third of the small changes. The Newsletter tab builds this week's issue, lists recent ones, shows the email exactly as subscribers would receive it, and lets an editor change the subject, add a note, or reject the draft. Newsletter settings — postal address, reply-to, send day, time and zone — are on the same tab, and the issue view says what a send still needs. Nothing on the page sends; approval is the next change.
The old digest is gone. generate-weekly-digest and lib/content/digest.js
are replaced by build-newsletter-issue, which runs the structured issue
builder. The old job's drafts had no links and nothing read them.
Edits carry the version they were made against. Every save and reject sends the issue's etag; if someone else changed the issue first, the page shows the server's sentence and reloads the stored version instead of overwriting it. The email preview renders in an iframe with an empty sandbox.
- Admin API for weekly newsletter drafts, and the newsletter settings (#504,
ADR 0030 §2a). Second of the small changes building the weekly newsletter.
Editors can list issues, read one rendered exactly as it would send, edit a
draft's subject and note, and reject it:
GET /api/cms/newsletters,GET|PATCH /api/cms/newsletters/{id}andPOST /api/cms/newsletters/{id}/reject. Nothing in this API sends.
cms/platform-settings/newsletter-settings stores what a send will need: the
postal address, a reply-to inbox (one on news.hybridcloudworks.com, which
receives no mail, is refused), and the send slot as weekday, time and IANA
zone. A partial save is allowed; the issue view reports what is still missing
and when an issue approved now would go out. The audit row records whether the
address and reply-to are set, never their values.
Edits are ETag-conditional, so a concurrent change is a 409 rather than an
overwrite, and an id that is not issue-YYYY-MM-DD is refused before Cosmos is
read. The page that uses this is the next change.
- The weekly newsletter's core library: what goes in an issue, how it is built, rendered and scheduled (#504, ADR 0030 §2a). No route, job or page uses it yet — the admin surface and the job swap are the next two changes, split so each is small enough to review on its own.
Sections are a registry. lib/newsletter/sections.js collects new articles
(linked to the URL publishing stored, never a guessed one), Microsoft
certification news with exam codes, and approved study and podcast episodes. A
section is one entry; one that throws costs its heading, not the issue.
An issue is a structured draft. issue.js stores the sections' items, a
plain-text intro and subject from the existing drafter, and room for the
owner's note; a week with nothing new builds nothing and asks no model, and
with the AI off the issue still builds. One issue per day, and an issue that
has left draft/rejected is never rebuilt.
The email escapes everything and re-checks every link. render.js escapes
each stored value once and drops any item whose link is not https or a site
path, however it got there; the footer carries the postal address and Resend's
per-recipient unsubscribe link.
The send slot survives daylight saving. schedule.js turns a weekday, a
wall-clock time and an IANA zone into a UTC instant per send, so Tuesday 09:00
Central stays 09:00 in March and November. The email-address check moved to
email.js so settings validation will not load the signup handlers.
- The newsletter signup works, with double opt-in (#504, ADR 0030 §3a). The
form on every page posted to a route that never existed. It now posts to
public/newsletter/subscribe, which emails a confirmation link fromnewsletter@news.hybridcloudworks.comand adds nobody to the list; the link opens/newsletter/confirm, and only pressing Confirm there writes the contact to Resend's Newsletter segment. This is the second of three steps; sending the weekly digest is the third.
Confirm does not trust the write. ADR 0030 named resend/resend-node#458,
a contact created unsubscribed: false coming back unsubscribed — which would
tell someone they are in and then skip them on every broadcast. So after
writing, confirm reads the contact and its segments back and answers success
only when both say subscribed, repairing once if they do not. Resend does not
document what creating an existing contact does either; the same read-back
covers it, and someone who had unsubscribed and confirms again is resubscribed,
because confirming is that consent.
Signup is a way to make this site email a stranger, so it is limited twice: five attempts per Cloudflare-verified caller per hour, and two confirmation emails per address per hour whoever asks. It never reads the list, so it answers identically whether or not an address is already subscribed. A filled honeypot is answered like a real signup and sends nothing.
Three choices keep the address out of places it does not belong. The token
rides in the URL fragment, which no server sees, so the address inside it never
reaches a request log. Loading the confirm page confirms nothing — mail scanners
open every link before the person does — and only the button POSTs. No log line
in either handler contains an address. The token's HMAC key is HKDF-derived
from RESEND_API_KEY, so there is no second secret to seed: whoever holds that
key can add contacts directly, and forging a confirmation grants them nothing.
- Resend replaces Klaviyo: its key has a row on the API-keys page and a test that refuses the wrong kind of key (#504, ADR 0030). This is the first of three steps. It swaps the plumbing; the signup route and sending follow.
What went. klaviyoProxy, both Klaviyo Key Vault references and their
catalogue rows, the Klaviyo card on the Integrations page, the Lists and
Campaigns tabs on the Mailing List page, and lib/klaviyo.js. Nothing wrote
to Klaviyo, so nothing that stored data is lost; the weekly-digest buttons,
the one part of that page that worked, stay.
What came. RESEND_API_KEY, a Key Vault reference and a catalogue row, and
a resend entry in connectionProbe that calls GET /domains on the server.
That call was chosen because it answers the question worth asking before
anything is built on the key: Resend refuses a key minted with sending access
only, and that key could never manage contacts or broadcasts. A pass also
reports how many sending domains exist, and zero gets its own sentence — the key
works and nothing can be sent yet. It records no key verdict, because a
refusal here does not always mean "remint".
Seed before apply. The API-keys page writes to Key Vault by catalogue name
and does not need the app setting to exist, so the key can be seeded once this
deploys and before the Terraform run that adds its reference. Done in that
order, monitor-unresolved-secrets.yml never sees RESEND_API_KEY unresolved.
Also corrected on the way through: docs/standards/variables-and-secrets.md
said infra/main.tf declares 21 Key Vault references. They are in
infra/functionapp.tf, and there are 25 after this change.
- ADR 0030 records the newsletter provider, and corrects what #504 said was broken. The evaluation #504 asked for is decided: Resend, with the cadence staying in the Function App's timer rather than moving into a vendor's scheduler.
The send allowance decides it, not the feature lists. Klaviyo's free plan is 500 emails a month. A weekly newsletter is 4.33 sends per contact per month, so that plan runs out at 115 subscribers — and at its own 250-contact ceiling the most it can deliver is fortnightly. Resend's marketing meter bills on contacts stored rather than emails sent, so 1,000 contacts receive a weekly newsletter for nothing. The first paid step is USD 40 a month at 5,000 contacts, against roughly USD 100 for Klaviyo at the same size; the record states plainly that this is a cliff and not a ramp, and sets a revisit trigger at 800 contacts so the jump is chosen rather than billed.
#504's description of the failure was wrong and the ADR says so. The issue
called the signup form "a control that looks like it worked" and compared it
to the Publer button that reported success for a 403. NewsletterSignup.jsx:46
checks res.ok and throws, so the 404 renders a visible "Subscription failed."
Nothing is silently discarded and nobody is told they subscribed when they did
not. What is live is a visibly broken control on every page — embarrassing
rather than lossy, and not a reason to rush the provider choice. Recording the
correction matters more than the verdict: the next session to read #504 would
otherwise act on an urgency that is not there.
The one lossy failure this could still produce is named as a risk instead:
resend/resend-node#458 reports a contact created with unsubscribed: false coming
back unsubscribed, which would capture a subscriber and then skip them on
every broadcast. The first write asserts the stored state rather than trusting
the call, and a test pins it.
Also decided: the public route contract names no vendor, so a provider change
is a handler change and the frontend does not move; Resend holds the list and
Cosmos does not mirror it, for the reason ADR 0029 keeps the feed as the one
boundary — two stores of the same truth disagree, and the unsubscribe is where
that becomes mailing someone who opted out; and new code targets segments.*
rather than the deprecated audiences.*.
- A documented, mostly-automated rollback for admin sign-in. The Entra split
left one recovery path — set
VITE_ENTRA_CLIENT_IDback and redeploy — living only in a pull request description and somebody's memory. It also stopped working the moment the API registration's redirect URIs were cleared, because a frontend pointed at a registration with no redirect URIs fails at the redirect.
docs/runbooks/admin-signin-rollback.md now carries it, and leads with how
to tell whether a rollback is even the right answer — a table mapping what
Admin → Health shows to whether this page applies. A removed App Role and a
wrong audience look identical from outside the portal and want opposite
responses.
deploy-azure-frontend.yml takes an optional entra_client_id_override, so
the rollback deploy is one dispatch and needs no repository-variable write.
Extending the real deploy rather than adding a second workflow is the point:
an emergency path that has drifted from the path it is meant to replace is
worse than no emergency path, and a copy of 340 lines of build-and-upload would
drift. Blank is falsy in a GitHub expression, so an ordinary run is unchanged,
and the run logs which id it built with so the record is in the run rather than
in a memory.
What stays manual, and why that is deliberate. Restoring the redirect URIs
needs Entra directory rights, and the deploy identity has none — infra/oidc.tf
explains that Azure Owner does not grant them. Granting CI Application
Administrator so a rollback is one click would hand every future workflow run
the power to rewrite app registrations, permanently, against an incident that
may never happen. scripts/rollback/restore-admin-signin.ps1 does that half,
reads the registration back rather than trusting the PATCH, and prints the
client id for the workflow input.
The identifiers live in the script because the two house rules meet here and
appear to conflict: the docs redaction gate rejects real GUIDs under docs/,
and the working agreement rejects placeholders in a line meant to be pasted. A
runbook full of 00000000-… would satisfy the gate and fail the operator.
scripts/ is not scanned, so the runbook says "run this" and the values it
needs are concrete. A test asserts the runbook contains neither a GUID nor a
placeholder, and that the runbook, the script and the workflow all name the
same input.
- Sign-in lands on a page built for it, not on the public home page (#520).
msalConfig.jssetredirectUri: window.location.origin, so Entra returned the browser to/— a page that had never touched MSAL, becauseAdminAuthGuardis lazy-loaded and mounted only under/admin. Nothing consumed the authorization code. The fix at the time wasuseAuthRedirectLanding, mounted fromApp.jsxon every route in the application — including every anonymous visit to a provider news page — so that a regex could watchwindow.location.hashfor a fragment that appears on one.
/auth/callback is that one page. initializeAuth() consumes the fragment
and MSAL's navigateToLoginRequestUrl — which defaults to true — returns the
user to wherever sign-in started, the mechanism the bare origin was working
around rather than using. The 2026-08-23 incident that prompted the hook is
carried into the new page's header so it is not lost with it.
The hook was kept for one more release, deliberately. A browser
mid-redirect at cutover, or a bookmarked stale fragment, still landed on /;
removing both halves at once would have turned those into a home page with a
dead #code= in the address bar that reproduces on every reload — the exact
symptom the hook was written for. It was removed in #531 once the API
registration's bare-origin redirect URIs were cleared, which is what closed
that window; see the Removed section.
A test asserts the path inside redirectUri is a route App.jsx declares.
That agreement is between a config string and a route table, so nothing that
renders a component would notice it breaking — and when it breaks, sign-in
fails in production and nowhere else.
Also here: MSAL now has loggerOptions at Warning in production, dropping
anything MSAL flags as containing PII. There were none at all, and all three
failures this codebase's comments are built around surface at Warning, so
Error alone would have shown nothing new on any of the days they cost.
/auth/* is noindex, nofollow — an indexed callback page would appear in
search results holding somebody's authorization code.
And the unreachable popup branch in entraAuth.js is gone, along with the
prefersRedirect() that returned an unconditional true. The reasoning is
kept and moved onto signIn; the code was four MSAL calls, and while it
existed a test had to mock loginPopup to reject in order to assert nothing
called it. A test arranging behaviour for unreachable code is a trap for the
next reader.
- The Entra configuration is reviewable in the admin GUI (#519). It was
visible nowhere. The tenant, audience, App Roles, scope and token version are
plain app settings rather than Key Vault references, so they never appeared in
secret-catalog.jsand never reached the Integrations page — and the SPA's own half (VITE_ENTRA_CLIENT_ID,VITE_ENTRA_TENANT_ID,VITE_ENTRA_API_SCOPE) was rendered nowhere at all. A scope that disagreed with the audience, or a SPA pointed at a different tenant from the API, was invisible until every call started returning 401.
Integrations gains a Microsoft Entra ID panel in the existing "Site
platform" group, whose own blurb already described exactly this — values the
site runs on, each one says what changing it breaks. Two columns: what this
browser was built with, and what the API says it enforces, the second from
getAuthExpectations. A page that compared the frontend against itself would
prove only that it agrees with itself. The group now survives an empty
credential list, because the API failing to answer is itself a configuration
symptom and hiding the panel then would be exactly backwards.
Health gains azp, scp and ver rows and three verdicts: the delegated
scope is present, the token version matches, and — the one that matters
structurally — azp differs from aud. azp is the client that asked for the
token and aud is the API it is for; one registration serving both makes them
the same GUID, which is the condition DECISION 3 warns about and #522 exists to
end. When they match, the card says so and explains why the delegated-scope
check keeps it safe meanwhile.
Identifiers only. No credential, no token, no oid, no email — the rule the
Integrations page states in its own header, and there is a test asserting the
panel renders nothing that looks like a JWT or a secret.
getAuthExpectations now also reports requiredScope, requiredTokenVersion
and labAgentAppRole, each read from the constant the guard actually enforces
rather than a copy, with a test tying the two together. '2.0' became an
exported constant for the same reason it was worth doing for the App Role
names: a second spelling is a silent drift.
- The Monday cron now refreshes the Azure study-guide outlines too, not just the catalogue (#499). The outlines shipped with #498 and refreshed only by hand, so between hand runs the detail pages could show an outline older than the exam list beside it.
This workflow was deliberately scoped to one file: its git add, its
artifact upload and its pull request body all named
frontend/src/data/azure/certifications.js alone, and that scope is the
argument for what the App installation token is allowed to reach. Widening
it to a second output was therefore its own reviewable change rather than a
line inside #498.
Six touch points, all shaped like the existing ones: the update job now
installs the functions tree as well — update-study-guides.mjs imports the
Listen & Learn parser from functions/src/lib/listen-and-learn, so node
resolves cheerio out of functions/node_modules and without it the import
fails at resolution before a guide is fetched; the study-guide script runs
after the catalogue script, because it reads eligibleCerts() out of
certifications.js and running it first would refresh the outlines against
last week's exam list; study-guides.test.js runs beside
certifications.test.js, separately because it asserts a different contract
(no outline is orphaned from a live exam); and the change check, artifact,
download, copy, git add, commit message and pull request body all gain the
second path.
Two things #499 did not list. setup-node was keying its cache on the
frontend lockfile alone while this job now installs two trees, so the cache
would have gone on reporting a hit while functions/ was fetched from the
registry every Monday. And the staging guard went from "stages the catalogue"
to "stages exactly these two paths and nothing else" — the one-file scope
was the security argument, so widening it to two deserves an enumerated
boundary rather than a second presence check. A third output is now a
deliberate edit to that test.
The failure policy is stated where it will be read. A partial failure is not a red run, and the script already implemented that: it refuses to write only when nothing parses, and keeps an exam's previous outline when that exam's own fetch fails. What was missing was visibility, so the pull request body now carries the study-guide summary — which already names every failed exam with its code, key and reason — plus a sentence explaining that a non-zero "Failed" count means the page still renders an outline, but one older than the date the file claims.
- Every live Azure certification page now carries the official study guide's "Skills measured" outline, area by area, each deep-linked to its heading on Microsoft Learn (#498). The owner's ask, from the AB-650 page: "Topics Covered" named a handful of themes, and what a learner needs is the table of contents Microsoft actually examines against, in Microsoft's own words, linked to where it lives. Azure only, as asked.
The data already existed in the pipeline and was being thrown away.
functions/src/lib/listen-and-learn/studyguide.js parses these guides into
weighted areas, sub-headings and objectives to script Listen & Learn
episodes — and the generator used the outline to write a prompt and
discarded it. But the generator only runs when the owner generates an exam,
and on 2026-09-11 exactly one Azure certification had ever been generated.
An outline that existed only after a generation run would have been missing
from nearly every page. So the outline ships as data, like the catalogue:
frontend/scripts/update-study-guides.mjs reuses that parser across
packages, and src/data/azure/study-guides.js carries 56 of 56 eligible
guides, 234 areas, 961 sections, zero failures, stamped DATA_AS_OF
and held by study-guides.test.js to the same rules as the catalogue:
every live exam with a guide has an outline, no outline is an orphan, the
join is the guide URL's tail because applied-skills entries share
site-assigned codes and a URL tail can only name one guide.
Each area's anchor is read off the Learn page, never derived. Learn
builds its ids from the whole heading — "(20–25%)" becomes "2025" — so the
parser's slugify(name) cannot reproduce them, and a guessed fragment would
scroll the reader to the top of the page while looking like a deep link.
The parser now captures the <h3>'s own id, null where the page
carries none, and the page renders a plain link in that case rather than a
broken one. Verified in the built output: the prerendered AZ-104 page
carries #manage-azure-identities-and-governance-2025.
It is in the HTML. Because the data ships with the site, the outline is in every prerendered Azure detail page for crawlers and for readers with JavaScript off; the average prerendered page grew from 27 kB to 32 kB.
The cost, stated rather than hidden: 85 KB gzipped on every Azure detail
page, against 3 KB for the AWS one, because the whole outline module is
bundled into the Azure detail chunk although each page needs one
fifty-sixth of it and already has that in its HTML. Not fixed here, on
purpose: splitting it per exam means a lazy import inside a Suspense
boundary so the hydrating render keeps the server HTML, which is a second
change to the hydration story useToday was built around and wants its own
review. Filed as a follow-up, with import.meta.glob — already used in
provider-coverage.test.js — as the pattern.
Not on the Monday cron yet, and why. update-learn-catalogue.yml is
deliberately scoped so its App token may touch one file; widening it to a
second output is its own reviewable change and is filed. Until then the
outlines refresh by npm run data:update:study-guides, which refuses to
write if nothing parses and keeps an exam's previous outline if its fetch
fails, so one bad page cannot erase a good outline.
-
The Azure detail hero carries the study podcast as one player with a chapter list (#498).
ListenAndLearnbelow the fold renders a full card per episode; this is the "press play" surface, so a learner who has already decided to listen does not scroll past the topics to find the button. One native<audio>element and the chapters in study-guide order, switching chapter by swappingsrc— one set of controls, one position, one thing a screen reader announces. Renders nothing unless an approved episode has audio: on 2026-09-11 that is AB-100 and no other Azure exam, so the slot is present on every page and visible on one. Named "Study podcast", not "Listen & Learn" again, because the full section already carries that heading and a screen reader would announce the feature twice with nothing to tell them apart; its<audio>is labelled "Play chapter:" for the same reason. -
Real brand marks on the Social Hub account chips, and a Threads entry that did not exist (#498). lucide-react v1 dropped its brand icons and the chips had been standing in with a chain link for LinkedIn, an @ for X, two heads for Facebook and a camera for Instagram — and nothing at all for Threads, which joined the workspace after the map was written. The owner's own report from the live page: "I cannot tell which account is which."
components/shared/BrandIcon.jsxcarries the networks' simple marks as inline SVG,currentColorso each chip's colour token applies, andaria-hiddenbecause every chip renders the account name beside it. -
/education, a front door to the Learn section that did not exist.routes.education(provider)appeared only inside a provider's own header, so a learner had to have already chosen a cloud to find the section at all, and the home page linked three of the eight — on hover. Two people were served by nothing: the beginner who does not yet know which cloud to learn, and the practitioner moving from one to another.
Eight provider tiles, each with the exams bookable today, the entry-level
one to start with, and that catalogue's own freshness line through the
existing CatalogueFreshness. Then a level table: rows are tiers, columns
are providers, so AZ-104 and SOA-C03 land in the same row and the reader
draws the line themselves. No cert-to-cert mapping is asserted anywhere —
the table folds each catalogue's own level field, and a test walks all
eight catalogues so a new level word fails the suite rather than silently
dropping a row.
Built entirely from data that already exists, and the page adds none of its
own. Prerendered like the other standalone routes. Its canonical comes from
socialTags in prerender.mjs, which derives one for every route and never
overwrites a page that sets its own — so this page sets a title and a
description and lets the prerenderer do the rest, exactly as /about does.
Confirmed in the built output rather than assumed.
"Bookable" includes expiring, deliberately. A published retirement
date does not close an exam, and excluding those would have hidden seven
sittable exams including AWS DVA-C02 and SAP-C02 from the one page meant to
help someone choose. Non-active rows carry a word — retiring, beta —
never a colour alone.
Twelve vendor level words fold into four tiers, which is the only
hand-written part and is tested. Foundational, Fundamentals,
Foundations and Practitioner all mean "start here" at AWS, Azure, GitHub
and the FinOps Foundation respectively. Two folds are lossy and are recorded
rather than hidden: VMware's Professional (VCP) and Advanced (VCAP) are
genuinely different rungs that share a cell, and AWS's Business is one
exam and a role cut rather than a tier.
The page surfaced two data problems it did not cause, both now filed: the
same GitHub exam is carried in two catalogues at contradicting levels, and
the Azure catalogue is the only one of eight with no DATA_SOURCE.
- The Plaud tab says when the 12-hour token refresh last ran, so the
rotation finally has a witness (#358).
refreshPlaudTokenrotates the Plaud MCP OAuth pair every 12 hours and writeslastTokenRefresh,oauthExpiresAtandlastTokenRefreshErrortomcp_servers/plaud. Nothing rendered any of them, so from every surface a person can reach, "the timer is armed" and "the timer has actually run" looked identical.
The log was no help either, and that is the part worth recording. The timer
reports success through log.log?.(), which is Information, and T-719 cut
host.json's Function category to Warning — so the trace is not ingested.
This is the T-766 witness gap in a different timer, found the same way:
by asking what a person would actually read to confirm the thing happened.
The fix was a rendering gap, not an API one. stripOAuthToken replaces
only the two token VALUES with booleans and spreads the rest of the
document, so all three fields were already being returned. Recording Hub →
Plaud → Connect now shows when auto-refresh last ran, when the access token
expires, and the last failure if there was one.
Three distinctions the panel keeps, each of which would otherwise mislead:
- Not yet read is not "never".
refreshStateisnulluntil the first read answers, and the panel renders nothing for it. Printing "never" for an unanswered read is a claim rather than a measurement — the same distinction this page already draws betweenunknownanddisconnected. - Connected is not refreshing. A document can hold an access token and no refresh token, which the banner already said; now it also says whether anything has actually rotated since.
- A failed rotation is shown, not swallowed.
lastTokenRefreshErrorrenders in its own line, so a revoked refresh token is legible before the access token quietly expires.
fmtWhen takes both shapes the document stores, because the timer writes
lastTokenRefresh as an ISO string and oauthExpiresAt as epoch
milliseconds. It returns empty for anything unparseable: a token page is the
wrong place to make someone wonder whether the date or the token is broken.
Review caught two more places the panel would have claimed more than it
knew, which is the defect this whole change exists to remove. A thrown read
set the banner to unknown and left the rotation panel showing the last
successful timestamp as though it were current, so one half was confident
about a read the other half called unknown; refreshState is now cleared
with the status. And an unparseable value formatted to empty while its row
still rendered, producing a label followed by nothing - which reads as a
broken page rather than a missing value. The expiry row is gated on the
formatted string, and a lastTokenRefresh that is present but unreadable
now says so rather than reporting that the timer never ran, because those
are different facts.
Confirmed live 2026-09-11, deployed with b70cd21f. The panel renders
on the deployed site and the owner confirmed it was working.
The deploy was also confirmed without a credential, by a second technique beside the route probe #489 recorded — one for Functions, one for the frontend. The admin BUNDLE is public even though the PAGE behind it is not, so the chunk was fetched from the CDN and searched for a string that exists only in this change. That reaches inside a page an unauthenticated session cannot open.
The chunk is RecordingHubPage-<hash>.js, and the hash is written here as a
placeholder on purpose: it changes with every build, so quoting the one from
this deploy would hand the next reader a 404 and a reason to distrust the
method. Find the current name by fetching / for the entry chunk, then
reading the lazy-chunk filenames it references.
The observation followed on 2026-09-11: the timestamp advanced, which is the full cycle #358 asks for, and that row is now ticked. The two were kept apart in this entry while they were apart, because seeing the panel is the precondition for the read and not the read itself — easy to conflate precisely because the panel is the only place the rotation was ever visible, so both arrive through the same screen.
Recorded as owner-reported rather than measured. The Connect tab is behind admin sign-in, so no automated check could read that timestamp — not the probes that verified these deploys, not a monitor, not an agent session. It was read by the operator in their own browser, and the issue comment says the same thing in the same words. "Observed" and "reported as observed" are different evidence, and an estate that has twice been misled by a confident report of something nobody measured should keep saying which one it has.
A third followed: the auto-refresh row rendered even with no refresh token stored, where the banner already says the access token expires on its own. "Last ran: not since this token was stored" beside that implies a refresh token exists and has merely not fired yet. The row is now shown only where auto-refresh can run. The expiry row is kept in that case and matters more there than anywhere, because it is when the connection stops working.
- The API-keys page refuses a credential paste that carries an invisible
character, a curly quote, or an Authorization header (#484).
rejectSecretValuealready refused leading or trailing whitespace, which is the visible half of a bad paste.String.prototype.trim()is no defence against the other half: it removes U+00A0 and U+FEFF and only at the ends, it does not touch U+200B or the text-direction marks even there, and nothing it does reaches the middle of a string. A value carrying one of those stores clean, resolves clean, and is refused upstream forever as "the key is wrong" - the exact sentence that cost #358 several days on a credential where nothing could see the stored string.
Six checks now run after the existing ones, deliberately last so a placeholder still reads as a placeholder rather than as a value with a space in it: invisible and zero-width characters, control characters, a matched wrapping pair of quotes, curly quotes, an auth-scheme prefix, and interior whitespace. Each names what was found, because "invalid" here would reproduce one layer earlier the same uselessness the feature exists to end.
Naming what was found turned out to be harder than writing the checks, and
review corrected the sentence twice. The interior-whitespace message said
"a space" while the check is /\s/ - and the wide part is exactly what
survives to it, since every ASCII whitespace character is a control
character refused earlier, so what actually reaches it is U+00A0, U+2028,
U+3000 and their neighbours. The label message said "part of the
Authorization header", which is true of Bearer and false of api_key:,
a YAML key far more often than a header. Both now say the accurate thing,
and the tests assert the quoted prefix rather than the prose around it.
Ordered most specific first, which is not cosmetic. A pasted
Authorization: Bearer <key> trips three rules at once, and the useful
sentence is the one about the header rather than the one about a space. The
scheme alternation is longest-first for the same reason: with bearer
ahead of bearer-api, a Publer value would match the short one and the
message would quote a scheme the operator never pasted.
A quote INSIDE a value is still allowed - only a matched wrapping pair is refused - and a regression test carries four real credential shapes from this estate's own catalogue so hyphens, underscores, dots, colons and mixed case keep working.
The module and its tests name every suspect code point as a number, never
as the character. A file about invisible characters that contains
invisible characters reads as binary to git and to grep, and the one byte
nobody can review is the one nobody can see; integrations/rest-proxy.js
recorded the same trap against its own control-character check. Both files
are asserted clean of the characters they describe.
Not built, and that is the decision rather than an omission: the "shape"
readout (length, alphabet, flags) from the deleted fix/358-secret-shape-diagnostic
branch. It answered "is the stored string the one I copied", which #358
answered another way, and it would put a description of a secret on a page
whose stated promise is that it never reads one back.
Confirmed live 2026-09-10, deployed with 5123493c. Three malformed
values were pasted at the API-keys page and each was refused by name with
nothing written: a value prefixed with an auth scheme, a value still wearing
its quotes, and a value with a space in the middle.
The confirmation was run FROM A PHONE, which is the authentic instrument rather than a convenience. The curly-quote case exists because a phone keyboard substitutes them, so a desk browser can only simulate the error this check was written for. The quoted value is refused either as wrapped in quotes or as containing a curly quote depending on the keyboard, and both are correct - which is worth writing down, because a reader expecting one exact sentence would read the other as a failure.
- Telegram, RSS.com and YouTube can be tested from the Integrations page,
without their credentials ever reaching a browser (#483). The three held
keys that are only ever read on the server, so their cards had a globe and
no beaker: #481 pointed each globe at the page where its credential is
minted, which is the best a card with no test can do, but "test everything
possible with a simple GET" was not actually satisfied.
connectionProbeis that test.lib/integrations/connection-probe.jsruns one hard-coded read-only call per service — Telegram'sgetMe, RSS.com'sGET /v4/podcasts, and a one-result YouTubesearch.list— behind the sameeditorguard the REST proxies use.
The caller sends a NAME, not a path. rest-proxy.js takes
{ path, method, body } and spends its header explaining why that is a
confused deputy, with assertSafePath as the boundary that makes it safe.
That boundary holds, but it is a denylist. Nothing here needs the latitude:
the page asks one question per service and there is one call that answers
it, so { probe: 'telegram' } is the whole request and the URL, method and
headers are built server-side from a frozen table. The property is stronger
than a validated path — the caller never supplies one. Object.hasOwn
rather than a bare lookup, so constructor and __proto__ are refused
like any other unknown name.
Two of the three authenticate in the URL, which made redaction a security property rather than tidiness. Telegram's token is the path and YouTube's key is a query parameter, so the ordinary habit of quoting a failed URL back into an error is a credential leak — the kind that ends up in a screenshot or in Application Insights forever. The built URL is never returned and never logged, and the upstream response is redacted before it is parsed, which is one choke point rather than four. That ordering was not the first draft: it redacted the error string and the log and passed the parsed body through untouched, and its own test caught the token sitting inside a provider error that echoed the request line back. The test asserts the absence on all three paths that can produce a string — a rejection, an unparseable body, and a transport error whose message quotes its target.
Only Telegram reports a key verdict, and the other two are a decision
rather than an omission. getMe takes the bot token and nothing else and
answers 401 when it is wrong, so a rejection has exactly one setting to
blame — and TELEGRAM_CHAT_ID stays unprobed for the same reason, because
a success says nothing about it. RSS.com's 401/403 split has not been
measured here, and the YouTube Data API answers 403 for a disabled API, an
exceeded quota and a referrer-restricted key alike; a light that reddens on
a quota day would send the owner to remint a key that is fine, which is
exactly what #358 cost two days. The beaker still reports those failures to
the operator who pressed it, which is the point of the button.
The Telegram token keeps its literal colon, and that trade has a second
half, and review found a gap in it. encodeURIComponent would send %3A, which Telegram would have to
decode before routing; if it ever did not, the probe would answer 404 and
the page would tell the owner their token is bad — a wrong answer to the one
question the button exists to ask. So the token goes in unencoded and
assertUrlSafe refuses any value carrying whitespace, a slash, a question
mark, a hash or a backslash, naming the SETTING rather than quoting the
value. It is not a defence against a caller, who never supplies this; it is
a defence against a mis-pasted secret quietly changing which URL is called,
of which a trailing newline is the common case. Redaction covers the
percent-encoded form as well as the raw one for the same reason: an upstream
that quotes its request target back shows the encoded bytes, and matching
only the raw string would sail straight past it.
% joined that refusal list on review, and it is the least obvious member.
Because the token goes in unencoded, a value carrying its own %2F is
decoded by the far end into the delimiter the character class had just
refused — so blocking the literal slash while allowing the escape that
becomes one was a gap rather than a boundary. No real Telegram token
contains a percent sign. The test fixtures were rewritten in the same pass:
they read 123456789:AAH… and AIzaSy…, the genuine Telegram and Google
key shapes, which a secret scanner cannot tell from live values and whose
false positive costs a real rotation. They now say not-a-real-… and keep
only the properties the tests need. Both found by Copilot review of
89288f8a.
Telegram answers { description } where the other providers answer
message or detail, so upstream-error.js and its browser mirror
proxyEnvelope.js learned that field — appended last, so nothing Publer,
Klaviyo or Linkie resolve to can change. Without it the probe would print a
bare HTTP 401, which is the thing #463 item 4 exists to prevent.
The YouTube probe costs about 100 of 10,000 daily quota units per press,
because the Data API prices search.list per call and not per result. That
is why it is a button, why the success message says the cost out loud, and
why it must never become a timer or a page-load check.
One test changed rather than being added, and the direction matters. The
page asserted that a credentialed service with no test points at the page
where its credential is managed, guarded by expect(untestable.length)
.toBeGreaterThan(0) so the rule could not go vacuous. These three were the
last three, so that guard now fails for the right reason — which makes it
the wrong guard. It is replaced by an assertion that the set is empty,
which pins today's fact and hands the rule back the moment anything joins.
Confirmed live 2026-09-10, deployed with 5123493c. All three beakers
were pressed: Telegram and YouTube reported connected, and RSS.com reported
that RSSCOM_API_KEY is not set.
That third result is the feature working, not failing, and the distinction is the one this repository keeps paying for. RSS.com's key has never been seeded, so "not configured" is the only honest answer a probe can give - and it is a different answer from "the credential was rejected", which is precisely the confusion #358 spent days inside. A card that said "could not connect" for both would have been useless.
The deployment itself was confirmed without a credential, by a method
worth keeping: POST /api/connectionProbe answered 401 while an invented
route name answered 404, on a route that did not exist in the previous
package. A guard rejection proves the route is registered; the 404 control
proves the 401 was not the edge answering for everything.
The health endpoint cannot answer this, and the reason is a trap worth
naming. It does report a generation, so it looks like a deploy
identifier - but that value is RUNTIME_CONFIG_GENERATION, written by
var.config_generation on a Terraform apply (T-513). It answers "which
configuration generation is this worker consuming", which is a different
question, and it does not move when a Functions package is deployed: it read
gate2-a8fd3c8 both before and after this deploy. Reading it as a build
stamp would report a stale package as a fresh one. Everything that would
identify the build - runtime version, site name, feature flags - was
stripped from that endpoint by T-402, because an unauthenticated inventory
is what host enumeration looks for. So the route probe with a control is the
available method, not a preference.
- The Azure Learn catalogue refreshes itself every Monday, and the Friday
Skills Hub scrape finally has a reader (#461 items 3 and 4).
frontend/scripts/update-applied-skills.mjshad only ever been run by hand — the file said "Last manual sync: 2026-04-16" until #464 — so it now follows three rules: every run stamps today's date intoDATA_AS_OFand the header line; a source that cannot be fetched or parses to zero items ends the run with exit code 1 and one sentence, and the file is not written (the title overrides no longer turn an empty poster into five entries); and lifecycle only moves forward (beta → active → expiring → retired), so a poster that still lists a retired exam, or an API that still says "(beta)" for one #464 made active, keeps the file's value and prints the disagreement — and an entry the sources no longer list is kept, never deleted, because dropping AI-102 would break itsreplacedBylink and the retired cards. A poster entry that neither the Learn API nor the file knows is reported and not added: the first live run parsed the PDF's "Full certification title" template label as exam MB-300. The regenerated blocks go through Prettier so a run that changes nothing but the date is a one-line diff..github/workflows/update-learn-catalogue.ymlruns it Mondays 06:30 UTC (and by hand) in two jobs modelled on the manifest workflow —npm ci --ignore-scriptsand the updater withcontents: readand no Azure identity, then a branch pushed with the manifest App's token and a pull request opened ready for review whose body lists every entry added, changed, removed, kept or disputed;scripts/learn-catalogue- workflow.test.mjspins the single-filegit add, the per-attempt branch name, the masked token and the absence of--draft. Run live on 2026-09-09 it added four applied skills the browse API lists (Build an agent in Microsoft Copilot Studio, Develop an agent with integrated tools, Manage GitHub secret scanning by using GitHub Copilot, Protect information in Microsoft 365 Copilot by using Microsoft Purview), renamed one (Build a generative AI chat app → Develop a Generative AI Chat App Using the Microsoft Foundry SDK), kept all twelve retired exams and five retired applied skills, and changed no status.GET /api/public/cert-events? platform=reads thecertEventscontainerscrapeSkillsHubRsswrites (lib/timers/skills-hub.js: id from the feed guid,type,certCodes,title,summary,link,pubDatestamped on every row,mentionedDates,source: 'skills-hub-rss', deduplicated by id) — platform validated against the provider keys, answered by scraper source since the documents carry no provider (only azure has one), projected to a listing allowlist,ORDER BY c.pubDate DESC, capped at 100 — registered in the route inventory and.azure/api-surface.json. The Azure education page merges the result over its statictimelineEventsby id (lib/certEvents.js: the earliest date a post mentions on or after its publication day is the event's day) in an effect, so the pre-rendered HTML and the hydrating render carry the static entries alone and the static entries remain when the list is empty or unreachable; the data file's comment that claimed the array was scraper-sourced now says which side is which. The first real scrape is Friday 2026-09-11 09:00 UTC; until then the route answers an empty list. - The owner's button: Listen & Learn reads with Gemini "Best" or "Economy",
set as a default and overridable per run (#436, #432). Owner instruction
2026-09-09: "have a button to go from gemini 2.5 to 3.1 and back — for
newer technologies, better is best, for older, cheaper is best." Two Gemini
TTS models are offered, both priced in
COST_TABLE: Best — newest voice, about twice the cost (gemini-3.1-flash-tts-preview) and Economy — cheaper (gemini-2.5-flash-preview-tts). The stored default is a newadmin_config/listen_and_learn_speechdocument,{ geminiModel }, edited from a Listen & Learn voice card on/admin/platformas a two-option control with each model's per-episode ceiling beside it; the route accepts only the two ids and answers 400 by sentence for anything else, following the podcast-feeds card's validation and write pattern. The generation form gains a Voice model picker with the same two options, defaulting to the stored value and carried in the job payload asttsModel, which the job refuses by sentence for any other id. Precedence in the job: the run's choice → the stored default →LISTEN_AND_LEARN_TTS_MODEL→ the module default (lib/listen-and-learn/speech-settings.js; a settings read failure fails the run rather than reading in a voice nobody chose). The 202 and the queued toast name the chosen model and its cost, and the episode'sspeechModeland its usage row record which one read it. The rule of thumb on both controls — newer certifications: Best; older ones: Economy — is guidance for the person choosing, not automation; choosing by certification age is a follow-up. -
One Recording Hub at
/admin/recording-hub— podcast transcripts and Plaud in two tabs (#442; wires #434, presents #435)./admin/recordingsredirects there andRecordingsPage.jsxis gone. The Podcast tab lists everypodcast_transcriptsdocument with its source chip (an article, linked to the editor and the public page; or a recording with its title), status,truncatedandaudioErrorbadges and a player; opens the review view with the transcript beside its source — key takeaways for an article, the recording id and the generator'sattributionLeaksas a warning for a recording; approves or returns to draft through the review route; renders thehost.rsscomline (publishing… while approval's publish job is pending / published / error with Retry / skipped), accepting the review route's 202-with-job-id as well as its 200, with Retry keeping a plain-toast guard for a deployment that predates the publish route; and lists the show's episodes frompublic/podcasts(read-only — season metadata editing is not in this change). The Plaud tab carries the Library, Upload and Connect behaviour unchanged, adds Script this on every library and stored recording (POST cms/podcast/transcripts/generate-from-recording, jobgenerate-podcast-transcript-from-recording:get_file+get_transcriptthrough the MCP with the stored token,source_listas the fallback,generateRecordingScriptunder thepodcastScriptfeature, then the same voice → upload → save → spend tail the article pipeline uses — now one sharedfinishTranscriptinlib/podcast/generate.jsrather than two copies; stored asplaud_<recordingId>orrecording_<id>with the generator's provenance andattributionLeaks), and audio upload for transcription through the Plaud Embedded Transcription API — verified against docs.plaud.ai on 2026-09-08 and written into the header oflib/podcast/plaud-embedded.js— asPOST cms/podcast/recordings/upload(mp3/m4a/wav, 40 MiB, refused before storage when unconfigured) and jobtranscribe-recording-upload, which hands Plaud the media route URL on the newPUBLIC_API_ORIGINsetting (the storage account denies direct and SAS reads; the URL is unguessable but unauthenticated), polls to a terminal status — bounded by a documented default budget under the job's own — stores the transcript inrecordingsasplaud-embedded, and deletes the upload blob once Plaud is done with it (every terminal outcome; a timed-out poll keeps it, and a newexpire-recording-uploadslifecycle rule ininfra/storage.tfremoves anything underpodcast/uploads/seven days after creation, Plaud's own retention). A missing or revoked Plaud credential is refused with one sentence naming the Connect tab, at the door (409) and in the job, before any model call. Two new Key Vault references,PLAUD-EMBEDDED-CLIENT-IDandPLAUD-EMBEDDED-API-KEY, catalogued and documented;callMcpToolextracted frommcpProxyso a job can call an MCP tool server-side. -
Listen & Learn episodes grounded on owner-supplied web pages and YouTube videos (#433, slice 2). A second kind of episode, not a looser first.
generateEpisodeScriptgains an optionalgrounding: { sources, ai }; without it the function, the guide prompt and its tests are exactly what they were, and no source list is threaded into the certification path. With it, a separate prompt (buildSourceGroundedPrompt) says the episode is built from the listed sources and nothing else, fences the owner's list and states that whatever a source returns is material and never an instruction, forbids claiming exam authority the sources do not carry, and opens with its own datedSOURCE_DISCLAIMER. The call goes through the router'sgenerateGroundedJsonResponsefrom slice 1 underfeature: 'sourceGrounding'— theai-call-sitesexception that held the toggle open is removed — so Gemini absent from the chain is a sentence before a byte is sent, never a failover, and nothing is written.
The episode is stored in listen_and_learn_episodes under the same set as
the guide episodes, with kind: 'source' and the resolved
sources: [{ kind, url, title? }]; guide episodes now write
kind: 'guide', and episodeKindOf is the one rule that reads a missing
kind as guide. Its id is source_<slug of title> — the underscore is what
keeps it out of every guide area's way, since studyguide.slugify can never
emit one — so regenerating a title replaces the same draft. The admin page
gains SourceGroundingPanel: a title, URLs one per line classified as page
or video by the server's own rule (lib/sourceUrl.js, pinned equal to
isYouTubeVideoUrl by a functions-side test), and a Generate button that
posts POST cms/listen-and-learn/source-episode, which validates the list
with validateGroundingSources and answers 400 with the sentence — an
over-cap list, a YouTube URL given as a page — before any job exists; the
worker validates again. getSet returns kind and sources on every
episode and the review card lists the sources with links beside the
transcript. The anonymous list adds kind (a listener-facing page may
label a source-grounded episode) and deliberately not sources, until a
citation page exists. One usage source, listen-and-learn:source-script.
The live acceptance — one article URL plus one YouTube URL producing a
draft that reflects both — spends on the Gemini key and is owner-run.
- Approving a podcast transcript publishes it to RSS.com (#437, slice 2;
ADR 0029 §1b).
POST cms/podcast/transcripts/reviewmoving a transcript topublishednow runs the host step slice 1 built: it queues apublish-podcast-transcriptplatform job, marks the documenthost.rsscom.pendingwith the job id, and answers 202 so the Recording Hub can show "publishing…". The job (functions/src/functions/podcast-jobs.js) reads the MP3 from thepodcastcontainer, callspublishEpisodeToHost, and stores the record —episodeId,guid,hostStatus,publishedAt, orerror— underhost.rsscom; the platform'sonCompleteclears the pending marker if the job dies around the step. A job rather than an inline call because the client's own deadlines (30 s presign, 300 s PUT, 30 s create) sum past the 230 s HTTP budget, and because a step whose failure must not change the approval has no business holding the approval's response.
Three outcomes are recorded as skipped, never as an error, so the hub
says what is true: not_configured when RSSCOM_API_KEY or
RSSCOM_PODCAST_ID is not seeded (the reason is isConfigured's own
sentence), no_audio when the transcript generated without audio (nothing
is uploaded without audio), and not_published when a transcript was
withdrawn to draft between enqueue and run. A publish failure never
un-approves: status, approvedAt and approvedBy are written by the
review and by nothing in the host path, and the tests assert the host
patch carries only host.
Retry is a route, not a regeneration. POST
cms/podcast/transcripts/{id}/publish (publisher) refuses anything not
published with 409, refuses to double-queue while a publish job is still
in flight, and otherwise runs the same step — which PATCHes the host
episode the document already names rather than creating a second one.
custom_link carries the article's public URL when the article document
has one (publicUrlOf, the Social Hub's precedence). The feed remains the
ingest boundary: nothing here writes podcasts, and the site learns of the
episode when timers/podcasts.js reads the show's feed. Listen & Learn
episodes still do not publish, pending the fourth decision on #349.
Nothing has been sent to the live API yet — the two secrets are unseeded —
so the first real publish is the owner's, with the key.
lib/podcast/publish-transcript.js + tests; handlers.js,
podcast-http.js, podcast-jobs.js; .azure/api-surface.json.
- The AI router can ground a generation on web pages and YouTube videos,
and can no longer lose a prompt part on the way to a model (#433, slice
1).
generateGroundedJsonResponse({ prompt, sources, feature, … })takes{ kind: 'page' | 'video', url }entries and sends them to Gemini's Interactions endpoint — the same/v1beta/interactionsthe Listen & Learn speech side has called since 2026-08-24 — asurl_contextfor pages and{ type: 'video', uri }input for videos. Nothing calls it yet; the source-grounded episode kind is slice 2.
It fails rather than fails over. The call resolves the provider chain
exactly as every other call does — portal order, disabled providers,
CONTENTFORGE_AI_PROVIDER — and then requires Gemini to be in it, because
Gemini is the only provider that can read either source type. If it is not,
the call throws AI_NOT_CONFIGURED with the reason: the key is not set, it
is disabled in the portal, or the pin names another provider. It never
hands on to OpenAI or Anthropic, which would answer confidently from the
prompt alone. Sources are validated before anything is resolved — http(s)
only, YouTube URLs are video and only YouTube URLs are, duplicates
dropped, at most 20 pages (Google's url_context limit) and 10 videos —
and an over-cap list is refused in a sentence rather than truncated. The
prompt tells the model that whatever a source returns is data, never
instruction, and every URL passes through the same fenceArticleText the
article episodes use, so a source cannot close the caller's fence.
A dropped part is now impossible. toGeminiParts, toOpenAiContent
and toAnthropicContent used to return null for any part shape they did
not recognise and filter it out; a fileData part — the shape the issue's
first comment assumed a YouTube URL would take — reached the model as
nothing, and the model answered without it. Each converter now throws a
400 for a shape it cannot carry, which neither retries nor fails over. The
one production caller passing parts (inspect.js alt text) sends only
text and image/* inline data and is unaffected. The Source grounding
feature toggle joins the AI Engine catalogue; ai-call-sites.test.js scans
the new entry point and carries a checked, self-expiring exception for the
toggle until slice 2 gives it a call site.
- Any published article can become a podcast transcript (#435, slice 2 of
#432). The generator #439 left unwired is now wired: a per-item Podcast
transcript action on
/admin/published— the end of the ContentForge workflow, on the already-live list only, because "published" is what makes an article final enough to read aloud — confirms, thenPOST /api/cms/podcast/transcripts/generateenqueues the newgenerate-podcast-transcriptjob. The job loads the article, refuses unlessisPublicDocumentsays it is published (before the model is called, so a refusal costs nothing and writes nothing), scripts it throughgenerateArticleScript, synthesises the audio, uploads it and saves the transcript as a draft. Reading and approving areGET /api/cms/podcast/transcripts,GET …/{id}andPOST …/review— the last atpublisher, because approval is what #437 will hang the RSS.com publish on.
Its own container, by owner ruling. The earlier analysis on #435 had
article episodes borrowing listen_and_learn_episodes under a reserved
setId; the owner ruled on 2026-09-08 that Listen & Learn is the Learn
section's product and content transcripts are the podcast's, so they are
two containers: podcast_transcripts (Cosmos, /id, ids article_<slug>
with plaud_<recordingId> reserved for #434) and podcast (blob,
article/<slug>.mp3). Both are declared in Terraform and wait on an
owner-confirmed apply. Until it runs, the job fails with a sentence naming
the Cosmos container and the apply rather than a bare Resource Not Found,
and a missing blob container is recorded in audioError with the
transcript still saved — every audio-stage failure degrades that way here,
because the transcript is the artefact and #436 re-voices it. The container
is class A in the export (44 authored, 61 full, 54 delta), the audio is
served through the media route, spend is recorded under podcast:script
and podcast:audio, and the model call answers to its own podcastScript
portal toggle rather than Listen & Learn's. Section counts and the content
manifest do not count transcripts yet: nothing public reads them until
the article-side player lands, and nothing in the portal lists them until
the Recording Hub (#442) ships — the confirmation says so.
- Groundwork for publishing episodes to RSS.com: the API client, the idempotent host-publish step, and the two Key Vault references (#437, slice 1; ADR 0029 §1b). Nothing calls the new step yet. The owner approved RSS.com Max on 2026-09-08 so that, eventually, approving an episode uploads it to the show rather than leaving the owner to upload it by hand; the approval hook that makes that happen, and the admin retry, are the next slice. What this slice settles is how the host is spoken to and what a publish writes on the document, so that the hook is a wiring change rather than a design one. The issue's first task was to confirm whether the API had left beta; it has not. Checked 2026-09-09: RSS.com's help article says the API "was introduced in December 2025 and is currently in beta" and that "small changes to the documentation or endpoints may occur". So the client is written to the issue's beta-tolerant design rather than waiting for a date nobody has given.
functions/src/lib/podcast/rsscom.js is the API, read from
https://api.rss.com/v4/openapi.json on that date and recorded in the
module header: X-Api-Key; GET /v4/podcasts; a presigned upload
(POST …/assets/presigned-uploads, then the bytes PUT to the returned
URL with no key attached, because it is a storage host); POST and
PATCH …/episodes; GET one or many; PUT …/transcript. It has
timeouts, a typed RssComError carrying the upstream status and detail,
and no retries — retrying a POST …/episodes inside a client is how a
duplicate lands on a public feed. isConfigured(env) answers with a
sentence naming which of RSSCOM_API_KEY and RSSCOM_PODCAST_ID is
missing, and an unresolved Key Vault reference counts as missing, the
same rule every other key here follows.
functions/src/lib/podcast/host-publish.js is the step, and it is
idempotent on the document: host.rsscom.episodeId present means PATCH
that episode, with audio re-uploaded only when audioPath changed since
the last publish; absent means upload, create, and record the id. It
returns the patch to store and never throws — a failure lands as
host.rsscom.error = { status, code, message, retryable } beside the
previous host record, and status, approvedAt and approvedBy are
never in the patch, so a failed publish cannot un-approve anything. The
host is told the truth: ai_content: true on every episode, plain-text
notes built from the summary and key takeaways, and schedule_datetime
set on create because the spec documents that as "auto-publishing as soon
as transcode completes" — without it the episode would sit as a draft on
the host and the feed would never carry it. One gap is recorded rather
than hidden: a create whose response is lost before the id is stored
would be repeated, because the spec offers no client-chosen key.
The two settings are Key Vault references in infra/functionapp.tf
(RSSCOM-API-KEY, RSSCOM-PODCAST-ID), catalogued on the API-keys page
under Social & audience, and listed in Required-Inputs §4.6. Not in this
slice, by design: the approval hook, the admin retry, and any ingest
change — the feed stays the way an episode reaches podcasts.
- A script generator for recording transcripts, sibling to the article
one (#434, generator only; #446).
generateRecordingScriptinfunctions/src/lib/listen-and-learn/recording-script.jstakes a Plaud transcript and returns the same script shapegenerateEpisodeScriptandgenerateArticleScriptreturn, through the same validation, byte fitting and prompt-injection fence — imported fromscript.jsandai/prompt-fence.js, with tests that swap the shared fence for a marker and prove the marker reaches the prompt, so a copy cannot drift. Nothing calls it yet: the admin surface, the stored episode and theUSAGE_SOURCESentry are the next slice, and the returnedsourceobject carries what that slice needs —kind: 'plaud', the recording id, title, time, duration, segment counts and whether the input was cut.
It is a retelling, not a transcript. The issue's rule is that the two
hosts must not attribute lines to the real people in the recording, and a
diarised Plaud transcript invites exactly that: every segment carries
Speaker 1, Speaker 2 or a name the owner typed in, and the obvious
script maps those onto Maya and Elena. So the labels never reach the model.
renderTranscriptForPrompt writes each segment as one line and a paragraph
break where the speaker changes — the shape of the conversation, with
nothing that could name or number a person. A persistent neutral marker
([A], [B]) was rejected because it is an alias, and a model that can
say "the second participant argued" is one sentence away from "Speaker 2
said". The prompt forbids naming or quoting anyone who spoke, the
disclaimer says out loud that the voices are not the participants, and the
result reports any transcript label the finished dialogue repeated
verbatim, for the review surface rather than as a refusal.
Empty or trivially short transcripts are refused before any spend, for
the reason resolveArticleBody throws: a prompt with nothing in it produces
a plausible episode about the title. Measured live on 2026-09-08, a
28-second device check has an empty source_list, which is the file this
catches. The input ceiling is 120 kB — about two hours of speech — and the
cut lands at a segment boundary, because a half sentence at the end of the
fence reads to the model as a transcription error to smooth over rather
than an edge to respect. The prompt is told, and so is the result.
normalizePlaudTranscript accepts the measured get_transcript shape
(segments[] of start_time/end_time/content/speaker in ms), the
get_file shape with its source_list, a bare utterance array, a pasted
plain-text transcript, and the { transcript } document POST
cms/recordings already stores. One thing is inferred rather than measured
and the header says so: the key that holds the utterances inside a
data_type: "transaction" entry, because both files sampled had an empty
source_list; the normaliser finds the array by shape.
- ElevenLabs reads Listen & Learn, and the run says what it will cost
before it starts (#436, ADR 0029 §2a). The owner approved a paid
ElevenLabs plan on 2026-09-08, which met the revisit trigger ADR 0029 §2
wrote for itself; §2a records the amendment and this is the code for it.
speech/elevenlabs.jsis a third entry in the provider switch, first in preference order whenELEVENLABS_API_KEYresolves, under the same contract as its siblings: MP3 out (mp3_44100_64, the 64 kbps the Gemini path already encodes to), so the blob path, the storedcontentTypeand the player are untouched. Every turn is aninputs[]entry with its ownvoice_id— Sarah for Maya, Aria for Elena, both premade, overridable per host — so a turn whose speaker has no voice is a hard error before a byte is uploaded, not after. Dialogue is chunked at the endpoint's documented 2,000-character ceiling and the parts concatenated, as the Azure path already does.
The test that said elevenlabs was not a provider is gone, on purpose.
It was the record of the deferral; speech/index.test.js now records the
order instead, and the assertion it replaced is described in that file's
header rather than deleted quietly.
Out of credit is a state a paid provider has and a free one does not.
The API answers it with 401 quota_exceeded; that is surfaced once, with
its status and a code, is never retried, and — unless a pin says
otherwise — moves the switch on to Gemini, which stays as the fallback for
exactly this. Any other failure still fails the area, because a fault hidden
behind a different voice is worse than a gap.
Cost is recorded and stated. A character is the output unit: usage rows
carry the billed count from the API's character-cost header (our own
count, flagged estimated, when the header is absent), and
COST_TABLE.elevenlabs prices it at USD 100 per million — USD 0.10 per
1,000 — so getCostEstimate and the usage page work unchanged. The job's
202 now carries speech: { provider, estimatedCostUsd, … }, a ceiling
computed against the script byte limit per episode, and the admin page
prints it the moment the run is accepted: "Queued — speech by ElevenLabs,
up to $7.20 (8 episodes × $0.90)". ELEVENLABS-API-KEY is declared in
infra/functionapp.tf and the secret catalogue; seeding it is the owner's
step, and until then the switch moves on to Gemini as before.
Incidentally fixed in the same page: useAuthReady() returns authReady,
and the page destructured ready, so the sets list never loaded on mount.
scripts/check-workflow-health.mjs— the guard that would have caught the three weeks. Split out of #426, which retired the workflow that prompted it on 2026-09-08.validate-deployed.ymlexplained in its own header why it could not pass from a GitHub-hosted runner, and still sat in.github/workflows/from 2026-08-18 to 2026-09-08: the knowledge was not missing, nothing was reading it. This reads run history from the Actions API and names any workflow with no success among its sampled runs, at least one failure among them, and no attempt of any kind newer than ten days. Run against the live repository on 2026-09-08 it namedvalidate-deployed.ymland nothing else — no false positives across the other eighteen.
It measures rather than asserts. There is no list of workflows in it to keep in step with the directory — a hand-maintained list would have needed somebody to notice the thing it exists to notice. A workflow added tomorrow is covered tomorrow.
Three verdicts, kept apart. healthy, broken, and unproven, the
last being "this tool is making no claim" and reached three ways: nothing
finished to read (never ran, or still running); everything that finished
stopped short of a verdict (cancelled, neutral, action_required, skipped,
stale); or a real failure still inside the staleness window, which is
somebody mid-debug rather than an abandonment. Counting a cancellation as a
failure would have flagged deploy-azure-frontend.yml, which was used
successfully three times the same morning. Staleness is part of the failure
condition rather than a separate finding, because a detector that fires on
ordinary work stops being read — the failure mode that allowed the three
weeks.
broken means "nothing succeeded and something failed", not "every run
failed", and the message says which. Review asked for the stricter rule,
with a mixed sample demoted to unproven; it was declined on the evidence.
A workflow that failed a month ago and had a run cancelled a month ago has
failed, has never worked and has been left — and under the stricter rule it
would be printed under a line reading "none of which reached a verdict",
about a run that reached a verdict of failure. One stray cancellation would
also have been enough to hide validate-deployed.yml itself. What keeps
the looser rule honest is that the verdict prints its own arithmetic:
"N of M sampled run(s) failed, none succeeded".
A trap found while building it, recorded in the file. The first draft
called /actions/runs?workflow_id={id}. That form returns HTTP 200 and
IGNORES the filter, handing back the repository's newest runs across every
workflow — so all nineteen came back "healthy", including the one whose
entire history is two failures. It is /actions/workflows/{id}/runs. The
script now also asserts that every returned run belongs to the workflow it
asked about, and exits 2 rather than 0 if that does not hold, because a
silently unfiltered response is indistinguishable from good news.
It runs as a second JOB in monitor-deploy-drift.yml, not a new file.
The owner's instruction on 2026-09-08 was that there are too many
workflows; that file already holds actions: read, already runs Node
without npm ci, already runs on a schedule, and is already the
delivery-health monitor (renamed "Monitor delivery health"). A separate job
rather than a second step, because "the frontend is four days behind" and
"a workflow has been failing since August" are different findings, and one
job conclusion would merge them into a red X that says neither.
The job captures stderr, and that is load-bearing. Every exit-2 path in
the script writes with console.error and nothing to stdout, while the job
captured the report with report=$(node ...). Measured: exit 2, zero bytes
captured — a red job with a blank step summary, in the one case an operator
most needs to read why. The invocation now redirects with 2>&1, and
check-workflow-health.invocation.test.mjs pins both that and the
capture-then-read-$? shape, because node ... | tee would report tee's
status and never fail.
Every refusal path exits 2, including the ones that used to throw. An
uncaught throw from the awaited main() exits 1 — measured — which in
this tool means "a workflow is broken", so a response the script could not
read was reported as a repository containing a broken workflow. Two places
did it. main filtered the listing on w.path and asked for runs by
w.id, and an entry carrying neither threw at w.path.startsWith(...);
listingRefusal now checks entry shape alongside pagination. And the run
listing was read as got.workflow_runs || [], where a missing array became
"no runs" — a silent mis-measurement reported as unproven — while a truthy
non-array threw at runs.filter(...); that guard is now runsRefusal,
pure, exported and tested, and it carries the workflow_id filter check
that used to sit untested inside main. The requests also pin
X-GitHub-Api-Version: 2022-11-28, matching check-deploy-drift.mjs (the
other job in the same workflow file), github-app-token.mjs and
open-manifest-pr.mjs.
Covered by scripts/check-workflow-health.test.mjs (42 tests, real run
histories as fixtures), check-workflow-health.invocation.test.mjs (4), and
one case in entrypoint-guards.test.mjs; the suite goes 295 → 342, main
having dropped from 300 to 295 when #426 deleted powershell-hygiene.test.mjs
with the script it read.
Every one proven load-bearing by mutation, each reverted afterwards.
Implementing the stricter broken rule failed four tests, two of them
written for earlier review rounds, with expected 'unproven' to be 'broken'
and the demoted workflow printing 2 run(s), none of which reached a
verdict. Making the broken message claim "every sampled run failed" failed
the arithmetic test. Removing 2>&1 from the workflow invocation, and
replacing the capture with a | tee pipeline, each failed their own
assertion in the invocation test. Removing the entry-shape guard from
listingRefusal failed with listingRefusal accepted {}: expected null not
to be null. Restoring got.workflow_runs || [] failed the shape test with
TypeError: Cannot read properties of null — the defect demonstrating
itself — and putting the leading truthy check back into the workflow_id
comparison failed fails CLOSED on a row it cannot identify at all. And
reverting the entry-point guard to the file:// template
form failed with expected +0 to be 2, the script exiting 0 on Windows
having run nothing — the same bug that once shipped in smoke-deployed.mjs.
-
The Plaud Connect tab takes the refresh token too (#342, T-518 Wave 5). It stored only the access token, while
refreshPlaudTokenrotates the pair with the refresh token and Plaud's access token lasts about a day — so the Library lapsed daily and the timer, once armed, would have marked the documentdisconnectedat its first firing. The tab now has a second field forrefresh_token(both come from~/.plaud/tokens-mcp.json), the API stores it write-only beside the access token — never returned, carried through PUT round trips, reported ashasOauthRefreshToken— and the banner says whether auto-refresh is armed. Reviewed against Plaud's documentation on 2026-09-05: the quickstart's client id and secret belong to Plaud Embedded, a different product (device SDK and transcription API) that nothing here uses; the MCP tokens are the two values that matter. -
cleanupSoftDeletedContentis dry-run untilCONTENT_HARD_DELETE=true, never deletes a document whose deletion mark has no recorded origin, and reports every run (#334). Before this the hard reaper deleted anything carryingsoftDeletedAtolder than seven days, and three different writers put that mark on a document: the admin soft-delete route, which also recordsdeletionRequestedBy; and the two rejected-content agers, which recordsoftDeletedReason: 'rejected_aged_out'. A migrated document with an old mark from the Firebase days carries neither, and it would have gone at the first firing. Now the pin is the T-302 rule applied to the one timer that deletes documents rather than blobs —infra/functionapp.tfseeds it"false"beside the two blob pins — and origin is the rule the pin does not lift: a mark with nodeletionRequestedByand nosoftDeletedReasonis counted, logged at Warning as a count, recorded by id in the audit entry that every armed run writes once it has examined anything (a dry run writes no audit document), and left for a human in the admin content queue'ssoft_deletedfilter.
Every run now logs one summary line, idle runs included: in dry-run, what it would delete by origin; when armed, what it did. Before this an idle run wrote nothing, not even its audit record, which is why Wave 3a had no witness at all. Traces carry counts only; ids go to the audit document.
- T-518 Wave 2 armed and observed: the two feed-ingesting timers now run,
and
podcastshas its first production write (#332).SYNC_RSS_FEEDSandFETCH_PODCAST_FEEDSwere added toenabled_timersin thehcw-azureworkspace in one apply on 2026-09-03, ahead of the 00:00 Chicago window. Same class, so they grouped: both ingest feeds and create content documents, neither deletes anything, and re-delivery is safe by construction —cacheFeedupsertsrss_cacheper feed, andprocessFeedreads each episode by id and preservescreatedAtbefore upserting.
Observed through the durable side effect, not the host trace — the first
wave closed on the #328 witness. The Executed rows the runbook's gate 4
used to read have not existed since #321 (T-766), so the evidence is what
each timer leaves behind, read through the public API by
scripts/verify-timer-witness.mjs:
syncRssFeeds— PASS on the first read: everyrss_cachedocument re-stamped, newestrefreshedAt2026-09-03T07:00:16Z, which is the 02:00 Chicago boundary of its every-two-hours schedule to the second. Nothing else writes that stamp at that minute — the adminfetch-rss-feedsjob had last run the previous evening — so this is the timer.fetchPodcastFeeds— FAIL on the first read (nothing newer than the May migration after the 05:30Z firing), then PASS at the 13:30Z firing on 2026-09-04, the first after the #330 deploy restarted the host at 13:24Z.podcasts.updatedAtis re-stamped on every episode each run, so a fresh stamp is the handler completing an upsert, not merely starting.
What the record cannot say. The 05:30Z miss is not explained. That
firing predates #330, so whatever happened — a feed that did not answer,
an empty feed, or a handler that did not run — was logged at Information
and dropped by the Warning gate; and the firings between the two reads
were not read. The next miss will be readable: since #330 a failed feed
writes an Error row and an empty feed a Warning row in the
Function.fetchPodcastFeeds category, with the query in the #330 entry
below. This is the collision T-766 describes, met on the first wave to arm
after it.
T-764 does not close on this entry. Its writer is observed; the closing
condition also asks for the azure podcast page to render an episode, and
that read is still owed.
- The arming gate reads the timer's durable side effect through the public
API, and needs nothing else (#328).
scripts/verify-timer-witness.mjstakes a timer name and an ISO--since, fetches that timer's witness from the Cloudflare-fronted API —rss_cache.refreshedAtforsyncRssFeeds,podcasts.updatedAtforfetchPodcastFeeds,content.publishedAtforpublishScheduledContent— and says whether the newest stamp is at or after the moment named. Noaz, no workspace, no extension, no telemetry plane: three fewer places for an observation to be lost. A malformed stamp never counts as evidence; an unparseable--sincerefuses rather than comparing againstNaN. There is deliberately no cron parser — the schedule is something the operator already knows, and a parser is a second thing to be wrong about.
Fifteen of the eighteen timers have no public witness, and the script
says so with exit 2 rather than a pass or a fail, because "cannot evaluate"
and "evaluated and failed" are different findings. The runbook's witness
table now covers all eighteen with the reason for each no, and the
script's test asserts that table names exactly the timers schedulers.js
and jobs-sweeper.js register — a timer added without a row fails CI
instead of arriving at a cutover with no gate. Thirteen tests; the drift
guard was mutation-checked by deleting a row and watching it fail.
Also records what #327 deferred: the log-analytics extension is now
Step 0's sixth item in the Cutover-Runbook, and that step's heading no
longer carries a count of its own items.
- Every provider page is live: the last 24 shipped-dark pages came out from
behind
ComingSoonPage(#325).finops,gcp,githubandterraformnow serve their full sections alongsideaws,azure,vmwareandansible. Owner decision 2026-09-02, taken as one release rather than section by section.
The pages were never unfinished. Each is structurally identical to its live
AWS or Azure counterpart — the same shared component with a different
provider prop — so the guard was two lines and nothing else:
ComingSoonPage's import and an early return above the real one. The routes
were already public; visitors have been landing on these URLs and reading
"coming soon". What changed is what those URLs answer with.
GUARDED_FILES in frontend/scripts/validate-provider-pages.js is now
empty, and the array and its checker deliberately stay. The mechanism is
what makes shipping a page dark reviewable: a page behind ComingSoonPage
with no entry there is an accident, and an entry there with its markers gone
is a page that went live without anyone deciding. Deleting the empty array
would delete that check for the next page that needs it. The validator reports
55 live files, 0 guarded.
Eighteen hardcoded date: 'Updated <month> 2026' stamps were removed rather
than refreshed, across terraform/ToolsPage, terraform/ModulesPage and
finops/FocusPage. Nothing maintains them: they were written months ago and
would have gone live in September still claiming February. A date no process
updates is a stale claim on a page that will never change, and removing it is
the fix that does not need doing again — the same reasoning applied to the
timer counts in TODO.md the same day.
- T-518 Wave 1 armed and observed: the platform's two safe timers now run
(#323).
PLATFORM_JOB_SWEEPERandMONITOR_PUBLISHING_PIPELINEwere added toenabled_timersin thehcw-azureworkspace in one apply, taking the estate from three armed timers of eighteen to five.
Two timers in one apply is a departure from Cutover-Runbook
step 5, and it was an owner decision rather than a shortcut. Step 5 reads
one timer per apply, observed before the next. Taken literally across the
fifteen that remain that is fifteen applies over roughly five weeks — three
of the fifteen fire weekly, so their observation windows alone are three
weeks — and every one of those applies restarts the Function App through the
workspace's permanent azapi diff. What the rule exists to prove is that
arming works at all, and that was settled before this wave: the mechanism had
been observed three times, once across the apply boundary itself
(publishScheduledContent, four skipped invocations then four ran, with the
flag as the only variable). What remains is per-handler behaviour, which
groups by risk. The precedent is CHECK_AGENT_HEALTH and
CLEANUP_TEMP_STORAGE, armed together on 2026-08-30 and recorded as a
departure with its justification.
What still does not group, and this is the limit of the concession: the
two timers that delete documents with no dry-run pin —
CLEANUP_SOFT_DELETED_CONTENT and CLEANUP_REJECTED_CONTENT — stay one per
apply. Grouping is a concession to calendar arithmetic, not to destructive
operations.
These two were the right first wave because neither can damage data.
platformJobSweeper re-enqueues jobs that have sat queued past
STALE_QUEUED_MS, closing the gap in functions/src/lib/jobs.js where the
job document is written before the queue output binding sends the message —
so a binding failure used to leave a job queued forever. Re-delivery is
safe by construction: the worker claims with an etag-conditioned replace, so
a duplicate message for a job that did start is skipped rather than
double-processed. monitorPublishingPipeline is a read-only watchdog.
Observed, not merely applied. The standard is step 5's fourth gate, and
all four were read from the workspace with
scripts/cutover/05-verify-timer.ps1 on 2026-09-02:
- Deployment: both flags read
truefrom a liveaz functionapp config appsettings listagainstfunc-site-prod-cus-01, with the armed set printing asCHECK_AGENT_HEALTH,CLEANUP_TEMP_STORAGE,MONITOR_PUBLISHING_PIPELINE,PLATFORM_JOB_SWEEPER,PUBLISH_SCHEDULED_CONTENT. - Runtime:
platformJobSweeperregistered with schedule0 */15 * * * *, read back from the host rather than from source. - Behaviour: the sweeper's own
re-enqueued … stale job(s), reaped … abandoned running job(s)line present in the trace stream, which is the handler reporting its work rather than the host reporting an invocation. - Invocation: both timers observed firing inside their windows.
The clock half of the gate applies to only one of the two, and saying so
matters more than the counts do. platformJobSweeper fires every fifteen
minutes, so it has no local-time dependency and the WEBSITE_TIME_ZONE trap
cannot express itself in its history — for it the gate is the count and the
handler's own line, and a timestamp check would prove nothing.
monitorPublishingPipeline runs 0 0 */6 * * *, which is 00:00, 06:00,
12:00 and 18:00 Chicago, so it is the one that can fire five hours early
and still pass a naive "did it run" check. Its ScheduleStatus.Last landed
on the intended local hour. A wave that reported one number for both timers
would have proven the weaker thing twice.
Arming two timers made four counts elsewhere in TODO.md wrong at once, and
they were removed rather than corrected. The section heading, the master-table
row, the attack-sequence phase and an owner-decisions row each said "the
remaining 15 timers" — accurate when three of eighteen were armed, wrong the
moment five were, and wrong again after every wave that follows. That is the
T-722 defect, in the file whose own wave table already carries the rule
("this table is the plan, and no count above it is"), so the fix is the rule
rather than an arithmetic update: the counts are gone and the enumerated
sentence that lists the armed timers by name — which cannot drift from itself
— is what remains. The one remaining count in that section was found by
review: enabled_timers was still described as "holding those three".
The per-run figures are not restated here. They were read by the operator at
their own prompt and the evidence standard is the observation, not a number
copied into a second document where it can drift from the first — the same
reason infra/frontend.tf states its ingestion reduction as a floor rather
than a measurement.
- The runbook now covers signing in, because five separate
azfailures cost round trips in two days and none of them named its own cause (#323).wiki/Cutover-Runbook.mdgains Step 0, ahead of the Entra step: the estate's single tenant and its four subscriptions in one table, the scoped device-code login, the subscription pin, and theaz account showline that says whether any of it worked.
The five, each recorded with what it actually was rather than what it looked
like: a plain az login handing off to the Windows WAM broker and spinning
with nothing to read; AADSTS700082, which reads as a permissions problem
and is a cached token past its 90-day inactivity window; a cached tenant that
is not the estate tenant — the expensive one, because reads then succeed
and return nothing, and an empty result from the wrong estate is
indistinguishable from a real absence; a tenant-wide login that enumerates
everything and leaves scripted reads apparently stuck, with the
az logout → az account clear → scoped-login reset written out; and
AADSTS50076 MFA warnings for two tenants that are not this estate — one of
them named hybridcloudworks.com, which is the trap — printed several loud
lines above the results table, so a command that fully succeeded reads at a
glance as one that failed.
The tenant id is written out rather than referenced. It is not a secret —
Entra publishes it for any domain at that domain's OpenID configuration
endpoint — and the alternative is a placeholder in a command meant to be
pasted, which .claude/CLAUDE.md prohibits, having twice paid for one.
Subscription ids were already written out in this file and in
scripts/cutover/05-verify-timer.ps1; no credential, key or token appears.
Also recorded there: sub-plat-mgmt-prod-cus and rg-mgmt-plat-prod-cus
swap their two middle tokens, which is easy to write backwards and fails as
a not-found against a name that looks correct.
-
TODO.md's handling rule is now a merge gate, not prose.
scripts/check-todo-changelog-movement.mjs, run by the Repository Policy workflow on every pull request, fails when a T-identifier leaves TODO.md without CHANGELOG.md carrying it — the "completed items are removed after the corresponding entry is present" rule, enforced at the same head. Renumbers and moves within TODO.md pass (the id still exists); earlier changelog entries pass (the changelog only grows); and a base that cannot be read exits loudly rather than passing, because a gate that cannot evaluate has not evaluated. Unit-tested for all four shapes and mutation-tested against a fabricated removal. -
The repository gains its own code-review skill (#316).
.claude/skills/hcw-code-review/teaches an agent to review a diff the way this repository's CI and reviewers do:SKILL.mdscopes the change, routes each touched path to a per-component checklist (frontend, Functions, infra, scripts/workflows, VPS agent and edge probe), and applies the cross-cutting checks the PR template and Repository Policy already enforce — secrets, content-free telemetry, pinning, the Markdown allowlist, TODO/CHANGELOG movement, and the owner-facing instruction rules in.claude/CLAUDE.md. Each reference file carries the component's real verification commands — the ones CI runs plus the review-time validators CI does not — so a review's Verification section reports what was actually executed rather than what was assumed. The directory lives under.claude/, already allowlisted as a harness directory inscripts/validate-repository-structure.ps1. -
T-519 closed: the reachability alert is armed, and the estate's one outage-surviving signal finally pages (#315). The blocker was what
wrangler.tomlpredicted: the Worker's secret held the Instrumentation Key, not the connection string, soparseConnectionStringthrew on every*/5invocation andavailabilityResultsstayed empty. Fixed 2026-09-01 with the piped command inedge/availability-probe/wrangler.toml— the value never touched a screen or clipboard — and verified as a chain, not a deploy:wrangler tailshowed a cleanOkinvocation; twelvesuccess == 1/ HTTP 200 rows landed on the 5-minute cadence (18:25–18:45 UTC and onward), twice the six a full PT30M window needs, so the rule's first evaluation ran against a populated window;availability_probe_alert_enabled = truewas applied in thehcw-azureworkspace;alert-api-reachability-prod-cusnow lists among the four scheduled-query rules inrg-web-site-prod-cus; and the registered function count read 122 both before and after the apply's expected Function App restart. Justification unchanged from the 2026-08-31 measurement: the GitHub-scheduled half of the detection pair delivers 22% of its hourly runs with a 12.7-hour worst-case blind window, and the probe runs on a scheduler GitHub cannot drop. Removed from TODO.md, whose open list drops to four; the standard Azure web test stays disarmed in Terraform (Bot Fight Mode, ADR 0024) with the Worker as the approved path around it. -
TODO.md gains one working order across everything open (#314). A repo-wide sweep (dedicated tracker, wiki backlogs, inline markers,
notImplementedcontract) confirmed the five-item table is complete, then added an "attack sequence" section that sequences all of it — the five items, the settings sweep, the optional seeds, the live confirmations — into seven dependency-ordered phases. It adds no items and restates no procedures: each phase links to the one section carrying the commands and success criteria, so the sequence cannot drift from the sections (the T-722 lesson, applied in advance). The 24 dark provider pages gain a row in "Owner decisions" — previously that gate lived only in the inline// TODO: remove to re-enablemarkers andfrontend/scripts/validate-provider-pages.js, outside the tracker, and the row records that re-enabling a page is two edits (markers deleted AND the path movedGUARDED_FILES→LIVE_FILES) because the validator asserts both directions. -
insertModuleIntoMarkdownhonours itspositionparameter (#314). The last genuine inline code TODO: both branches appended to the end, so a caller passing a real index got a silent no-op. It now string-splices the serialized module directly ahead of theposition-th<module>tag, leaving every other byte of the document — trailing prose included — where it was;-1or an index past the last module appends, byte-identical to the old behaviour. Deliberately NOT parse → splice → rebuild: an insert makes the module list outnumber the placeholders, andrebuildMarkdownWithModulesappends the surplus at the document end, so a middle insert would move the last existing module past any trailing prose — the first draft did exactly that, and review caught it. Four new test cases pin the contract as exact document bytes. -
The pre-rendered DOM is hydrated instead of discarded (T-714, #296).
main.jsxusedcreateRoot, so 120 pre-rendered documents were built, shipped and thrown away at boot. It now callshydrateRootwhen the mount point'sdata-prerendered-routestamp matches the live path, seeded fromdata-prerendered-seedon that same element, and client-renders exactly as before when it does not.
The stamp is the load-bearing half, not defensive programming.
staticwebapp.config.json's navigationFallback serves /index.html — the
home page's markup — for any path without a file of its own, at HTTP 200.
Every /admin route arrives that way. Hydrating on "the mount point has
children" would have mismatched on the busiest pages in the app.
Five Playwright tests drive a real browser, including a node-identity probe
that proves the server DOM is reused rather than coincidentally identical,
and both guards are mutation-tested. onRecoverableError reports a mismatch,
which was silent in a production build — the failure mode that would have let
this regress to wasting every document with nobody noticing.
check-tfc-plan.mjs --commit <sha>(T-724, #298). The tool resolved the workspace's LATEST run, which is whytfc-plan-check.ymlrefused to run per-pull-request: the check would have been green, or red, about a run nobody asked about.--commitresolves the run HCP Terraform planned for a given commit, through the configuration version's ingress attributes, and the workflow takes a matchingcommitdispatch input.
"No run for this commit" returns 2, not 0 — it is not "the plan is boring".
An absent relationship is skipped (CLI-driven runs carry no configuration
version); a dangling one throws, so an ignored include= cannot
masquerade as "no run for this commit". That distinction was found in review:
the first draft documented throwing on unreadable shapes and then skipped
exactly those cases.
-
scripts/workflow-write-permissions.test.mjs(T-726, #298). A ruleset bypass is granted to the Actions token, not to a workflow, so every workflow holdingcontents: writecan push tomainpast all twelve required contexts. This pins that set to a reviewed two, each with a written justification. It bounds the exposure; it does not close it. Mutation-tested: granting the permission toiac-validate.ymlfails the guard. -
Deployment drift is measured now, instead of being discovered by accident (2026-09-01).
deploy-functions.ymlanddeploy-azure-frontend.ymlare bothworkflow_dispatchonly, by a recorded decision — enabling a workflow and enabling auto-deploy-on-merge are separate choices, and only the first was made. So merging deploys nothing, and nothing said so.
That one gap produced both of 2026-08-31's incidents, hours apart, each found by accident rather than by a check:
- The manifest route merged at 2026-08-30 02:45 UTC against a Function App
last deployed at 01:21.
publish-content-manifest.ymlthen failed with a 404 for two nights, and its own error message blamed the app — which was healthy, reporting 121 registered functions throughout. - The frontend was 35 commits behind, including a hydration change and a sanitizer hardening, found only because someone deployed for another reason.
AGE, NOT COMMIT COUNT, and that is the whole design. The first incident was ONE commit behind; the second was THIRTY-FIVE. No count threshold separates them — catch the one and you fire on every ordinary merge, tolerate ordinary merges and you miss the outage. What they share is that both sat undeployed for days. Age tolerates the normal merge-then-deploy gap, which is the entire point of dispatch-only releases, and still catches a change merged and forgotten.
monitor-deploy-drift.yml runs every four hours and fails when a service has
been behind for 24 hours or more, emailing the owner through GitHub's own
notifications — the mechanism monitor-functions-registered.yml already uses,
needing no action group and no dependency on the subscription being watched.
It reads only the GitHub API: the last successful run of each deploy workflow,
and the commits on main touching that service's paths since. No Azure, no
OIDC, no environment:, and contents: read plus actions: read.
Drift is measured per service over the paths that service ships, so thirty
commits touching only wiki/ leave the Function App exactly as correct as it
was.
On running this from a schedule GitHub delivers 22% of the time, said plainly rather than assumed away: for an outage detector that is a real problem, and here it is not. This watches a condition measured in days against a threshold measured in hours, so a check landing every 4.6 hours on average has ample margin against 24. The workflow header records that the reasoning stops holding if the threshold ever drops near the delivery gap.
36 tests, and six load-bearing decisions are mutation-verified: reading only
the first page of commits, dropping the de-duplication, defaulting an
unreadable commits payload to an empty page, taking the newest commit instead
of the oldest, > instead of >= on the threshold, and leaving a raw | in
a table cell. All six fail the suite when introduced.
All five fail in the same direction — reporting a service as healthier than it is — and that direction is the actual finding. Every defect review caught in this file, and every one caught while writing it, made a stale service look current. None made a current service look stale. A monitor whose bugs all point at "everything is fine" fails the one way it must not, so the mutation set is exactly those.
The sixth is a legibility failure rather than a correctness one, and belongs
with them anyway: commit subjects and error text are not this script's to
constrain, and a | in either ends its Markdown cell early — the row grows a
column, everything after it shifts, and the table stops rendering as a table.
That table IS the report, read to decide whether to deploy, at the hour a
monitor tends to fire. Newlines are folded for the same reason.
Two defects were caught before merge and are worth recording:
oldestCommitoriginally took the last element, on the true-but-narrow grounds that GitHub returns commits newest first. That holds for one page of one path and nothing else — the merged list is newest-first only within each per-path segment, so with two paths the last element is the second path's oldest, which can be far newer. Found in review. It now takes the minimum by date and assumes no ordering at all, which removes the class rather than the instance. The same review found the missing pagination and de-duplication.-
The
setup-nodepin was a SHA appearing in no other workflow here — invented rather than copied, which is the exact defect pinning by SHA exists to prevent. It is now the pin the other eight workflows use, and both action SHAs were verified against.github/workflowsbefore commit. -
The three repository-resolvable test-coverage follow-ups. API base resolution — the original line asked for
api.jswithVITE_BACKEND_PROVIDER=azure, a switch that no longer exists because the GCP backend is gone and the Azure base is the only one.functionsBase.test.jsnow pins what replaced it:getEndpointcomposes an authenticated route onto the configured base in both topologies and throws naming the route when it is unset, an anonymouspublicApiread goes to the same base, and a source scan fails ifVITE_BACKEND_PROVIDERor anyVITE_GCP_*variable reappears — a reintroduced switch would mean a second resolution path, which is the defect that file exists to prevent. Public content limits —limitandoffsetcome straight off an anonymous query string, sopublic-reads.test.jsnow covers non-numeric, empty, zero, negative, fractional, oversized,Infinityand at-the-ceiling values on bothlistContentandlistPodcasts, including that a negative limit clamps up to one item rather than producing an empty slice, that an offset past the end is an empty page with an honesttotal, and thatlimit=0reads as unset. Partial configuration updates —admin-integrations.test.jsnow pins that a PATCH omittingoauthTokennever sends the key (so the merge cannot clear it), that the untouched token stays out of the response built from the merged document, that the read artefact is dropped, that a revoke remains an explicit empty-string write, and that anai-providerspatch touches only the fields it names.ai_providersdocuments holdapiKeyEnvVar, the name of a server-side setting, never a key —oauthTokenonmcp_serversis the only secret value either collection stores. -
The visitor-facing upstream delta (T-409). From Site-Main 088f458, with their tests:
RichTextBody(architecture and framework overviews render markdown as markdown, HTML as sanitised HTML),CoderCornerSnippet CodeBlock(the snippet, language and repository link the coder_corner contract requires now render; fenced code gets highlighting and a copy button),WafAssessment+ the vendor Well-Architected pillar sets (a Well-Architected tab when an architecture carrieswaf),FeaturedArchitecturecolorClasses(the AWS/Azure galleries' featured panel is data-driven), and the Ansible and VMware education data, rendered through a newEducationTrackscomponent with level filter, learning paths and resources.- The eleven Firestore triggers as six change-feed functions (T-324).
functions/src/functions/change-feed.jsregisters oneapp.cosmosDBfunction per watched container on the identity-based binding (COSMOS_CONNECTION__accountEndpoint+__credential = managedidentity, never a connection string), each with its ownleasesprefix. The before-image substitutes are ported from Site-Mainlib/triggers/: value markers (image mirrors, Publer push), the rising-edge claim on an etag-conditioned replace (AI cover, slug page), the activation stamp (Telegram alerts) andcontent_stats_markers(dashboard counters, idempotent). Image mirroring keeps the{docId}/images/…blob scheme and serves through the media route; the template cover is stored as SVG; the AI cover calls Replicate over REST. The three deletes the feed cannot see:DELETE /api/cms/content/{id}anddeleteContentItemmove the counters,DELETE /api/cms/social-posts/{id}un-publishes on Publer first, andDELETE /api/cms/blogs/{id}is new (publisher, audited).lib/notify.jsis the Telegram notifier with its per-source cooldown; resolve/reopen clearactivationNotifiedAt. - The external-ingestion timers (T-323, closed).
syncSocialCalendarScheduledreconcilessocial_postswith Publer (matched posts take Publer's state, vanished ones are marked deleted, unmatched Publer posts becomepubler_<id>);fetchBlogListingsscrapes eleven non-RSS listing pages through Firecrawl's v1 REST structured extraction intocontentdrafts in the RSS shape;fetchPodcastFeedsupserts PodBean episodes intopodcasts. Each skips itself while its key is a Key Vault stub. Three moreFEATURE_FLAG_*settings, all"false";SYNC_SOCIAL_CALENDARstays off until the cutover delta import (D12). Fifteen of sixteen timers are now registered;refreshToolServiceCacheScheduledstays demoted with Cloud Tools. - Twelve of the sixteen timers (T-323).
functions/src/lib/timers/carriesgenerateReviewerDigest,cleanupRejectedContent(soft),cleanupSoftDeletedContent(hard, with linked blogs andcontent_versionsrows),monitorPublishingPipeline,checkLiveLinks,reVerifyCertifications(republishes the certifications snapshot),cleanupUnusedCertImages,scrapeSkillsHubRss,refreshPlaudToken,forgeScheduled, and the two stubs —cleanupTempStorage(prefix + age, not an orphan sweep: T-302) andcheckAgentHealth(T-401) — each a factory with injected store/fetch/storage, registered inschedulers.jsthrough one flag-gatedtimer()helper with the §4.2 NCRONTAB. The two blob-deleting timers are dry-run untilTEMP_STORAGE_CLEANUP_DELETE/CERT_IMAGE_CLEANUP_DELETE. Digest, alert and system-audit records go throughlib/timers/workflow-records.js. Ten newFEATURE_FLAG_*app settings, all"false". forge-articleandgenerate-weekly-digestplatform jobs; the stale-job sweeper. ContentForge's pipeline is ported whole (functions/src/lib/content/forge*.js,drafting.js): dedupe against the published corpus, admin-editable profile and prompts fromadmin_configwith code defaults, format rotation, the forge module instruction and word soup, deterministic dash scrub / banned-phrase scan / module repair, the best-fit weighted grader with its keyword prescreen,forge_readyvseditingrouting, version + audit +forge_statswrites. The weekly digest drafts from the last N days of live content intonewsletters(dryRunpreviews); the Mailing List page gained the preview and draft buttons.platformJobSweeper(every 15 min,FEATURE_FLAG_PLATFORM_JOB_SWEEPER) re-enqueues jobs leftqueuedby a failed output binding — the gap lib/jobs.js documented.generate-listen-and-learnis deferred to T-411 (three Google services, no frontend here), closing T-322.batch-inspectplatform job — the article inspector, ported.functions/src/lib/content/carries Site-Main'sscrapeArticle(fetch+ cheerio + turndown; strict TLS; reader and headless fallbacks only behindCONTENTFORGE_SCRAPE_FALLBACK_ENABLED/CONTENTFORGE_HEADLESS_FALLBACK_*),extractPublishedDate, the voice / format-rotation block (pickNextFormatoffscrapedAtin Cosmos, fails open), the verbatim analysis system prompt, the critique gate with one automatic revision, andbuildInspectionUpdateDatawith its upstream tests. The job (inspect-jobs.js) selects up to 25ingesteddocuments —inspectTrigger: truefirst, then unflagged ones that have not failed — inspects each 4 s apart, recordsinspectErroron failure and keeps going; results are counts and ids only.OpsHealthPage"Batch Inspect" now runsrunJob('batch-inspect', { limit: 10 });batchInspectleft the RPC contract. Not ported: the architecture-diagram (multimodal) path — such documents record aninspectErrorsaying so — and cover-on-inspect.- AI router (
functions/src/lib/ai/router.js, T-322 §4.4) — ported from Site-Main'sai-model-router.jswith the provider model the owner chose on 2026-08-21: a provider is on when its key is present. Anthropic, OpenAI and Gemini (public API by key; Vertex dropped — ADC is a GCP identity the app cannot hold), resolved in that order or pinned byCONTENTFORGE_AI_PROVIDER; an unresolved Key Vault reference counts as no key; no key →AI_NOT_CONFIGUREDwith a sentence naming the three secrets.fetchinstead of axios; purpose → model table, JSON repair round trip, retry on 408/429/5xx, usage capture with cost estimates and the Anthropic prompt-cache marker all kept. 15 tests, none touching the network; the upstream cost tests came across. fetch-rss-feeds— the first real platform job (T-322), ported from Site-Main'sprocessRssFeeds: 20 feeds across 8 providers throughrss-parser, onerss_cachedocument per feed withitems[]capped at 20 on write (T-319's write-time cap), newcontentdrafts through the existing four-stage dedup (≤ 10 per feed), and thehomepage_feeds/latestround-robin aggregate. The admin "RSS Fetch" button enqueues it viarunJob()instead of callingfetchRssFeedsManual(which never existed here); thesyncRssFeedstimer stub now runs the same ingest every two hours behind its flag. TLS failures skip the feed with the reason recorded; one feed failing never abandons the sweep. 17 new tests. Not ported: the Telegram alert on feed errors — errors are in the job result.- Platform jobs — the pattern for every handler over Flex Consumption's
230 s HTTP cap (T-322 scaffold).
functions/src/lib/jobs.js: a job-type registry,POST /api/enqueueJob(editor; type allowlist, per-type payload cap; 202 + jobId; message to Storage Queueplatform-jobsthrough an output binding on the identity-based host connection),GET|POST /api/getJob(viewer), and a queue-triggered worker that claims with an etag-conditioned replace — at-least-once delivery never runs a job twice — and recordssucceeded/failed/timeoutwithout rethrowing into the queue. Newjobscontainer (30-day TTL, indexed likelab_jobs). Client:frontend/src/lib/jobs.jsrunJob()enqueues and polls with the Labs backoff. Built-in typenoop. 14 new functions tests, 5 frontend tests; the route inventory now asserts the worker is the only queue trigger. infra/scratch.tf— the migration rehearsal estate.cosmos-site-sbx-cus(serverless, keys off, the same firewall shape, the samehcwdatabase and the same 72 containers from the same generated spec) andstsitesbxcus01(the five content containers plus a privatemigration-reports) in their own resource grouprg-db-site-sbx-cus, created only whilecosmos_scratch_enabled/storage_scratch_enabledare true and destroyed when they are not. Mirrors production's posture on purpose: a key-authenticated rehearsal against an open account passes while proving nothing about theDefaultAzureCredential+ RBAC path production takes. Outputs viaone();set-github-variables.ps1wave 2 seedsCOSMOS_SCRATCH_ENDPOINT,STORAGE_SCRATCH_ACCOUNTandSCRATCH_RESOURCE_GROUPfrom them, and leaves them alone while null.scripts/migration-probe.mjs. OneSELECT VALUE COUNT(1)that runs before the export and classifies a Cosmos 403 asfirewallorrbac— two unrelated causes the SDK error does not distinguish, and which would otherwise surface only on the first upsert after a full export.scripts/migrate-storage-to-blob.mjs+scripts/lib/storage-manifest.mjs. Manifest-driven GCS → Blob--inventory | --copy [--dry-run] [--overwrite] | --verifyon@google-cloud/storage+@azure/storage-blob, idempotent bygcsmd5metadata, carryingcontentType/cacheControl, with a verify that compares counts, bytes, every object's MD5 and a deterministic byte-for-byte sample.--inventoryexits 2 on an unmanifested prefix, mirroring the Firestore preflight. A vitest suite asserts every target container is one of the five Terraform names. Replacesmigrate-storage-to-blob.sh.- Wiki pages
Migration-RunbookandPhase-4-Data-Migration. Referenced from eleven places (README, the plan,_Sidebar, the workflow, the manifest header); neither existed. The runbook is the twelve-step operator sequence with the evidence each step produces; the Phase-4 page is the decision log. WEBSITE_TIME_ZONE = "America/Chicago"on the Function App. Eight of Site-Main's sixteen schedules are declared in that zone; NCRONTAB on Linux evaluates in UTC unless told otherwise.storage_resource_groupoutput, pairing withstorage_accountthe wayweb_resource_grouppairs withfunctions_storage_account— whatmigrate-data.ymlscopes its per-run firewall window to.- The HCP Terraform → Azure bootstrap, which existed nowhere.
infra/providers.tfdeclares theazurermprovider with no credential — correct, because runs execute under HCP Terraform dynamic provider credentials — but the identity those credentials assume has to exist first, and nothing in this repository could create it.infra/oidc.tfcreates the GitHub Actions identity, which only exists after a successful apply. Terraform cannot create the credential Terraform authenticates with. A repository-wide grep forARM_CLIENT_ID,TFC_AZURE_*andapp.terraform.ioacross.tf,.ymland.mdreturned nothing: the first apply had no documented path to authenticate, and the gap was invisible to file-by-file review because every individual file was correct and only the join between them was missing.
scripts/bootstrap-terraform-oidc.ps1 closes it. It creates
rg-hcw-bootstrap, the id-hcw-terraform user-assigned managed identity,
two federated credentials against https://app.terraform.io — one per run
phase, because Entra matches token subjects exactly and case-sensitively
with no wildcards, so a single credential leaves every apply failing at
authentication while every plan succeeds — and Contributor plus Role Based
Access Control Administrator at subscription scope (Contributor cannot
create the role assignments infra/ declares; RBAC Administrator cannot
grant Owner, so the identity cannot escalate itself).
A managed identity rather than an app registration, for the reason
infra/oidc.tf already documents: app registrations need Application
Administrator in Entra, which Azure Owner does not grant. The identity is
deliberately outside Terraform state, in its own resource group —
Terraform managing the credential it authenticates with means a destroy or
a bad plan locks the workspace out of the subscription with no way back.
The script is idempotent and preflights before it proposes anything: CLI
present, signed in, tenant matches, subscription visible, role-assignment
rights held, Microsoft.ManagedIdentity registered. Sign-in is performed
by the script rather than demanded of the operator — being signed in to a
different directory is the normal state for anyone working across tenants,
so it runs az login --tenant itself and re-reads the account afterwards,
because a directory switch also changes which subscriptions are visible.
-DeviceCode covers sessions with no browser of their own (SSH,
containers, Cloud Shell) and the case where the browser keeps reusing the
wrong cached account; the script falls back to it automatically when the
interactive flow fails, since that failure is environmental — no display,
no loopback — more often than it is a credential problem. It handles the
fresh-tenant case explicitly — a Global Administrator holds no Azure RBAC
by default, which produces errors that suggest the wrong fix, so
-ElevateAccess takes the documented one-time root-scope elevation, grants
Owner on the target subscription, and removes the root grant again.
Documented in Deployment Runbook §0 (which now tables the two OIDC
handshakes side by side — confusing them strands the operator hunting for a
CLIENT_ID that does not exist until after the first apply), CHECKLIST §8
(the four workspace environment variables, contractual and exempt from the
2-word rule), and REVIEW §4.0. The iac-repo-standardizer agent and the
IaC Repository Standard both gained a bootstrap identity section making
this the first thing audited on any repository, since the failure
generalizes to every credential-free IaC repo.
-
Free-tier disposition recorded on the Cost-Analysis wiki page (now wiki-as-code, staged in
.github/wiki/). Decisions from the workload owner's free-services meter review: runner image stays on Docker Hub (ACR rejected — month-13 cost for a failover-only image); Cosmos free tier is unusable by design (serverless); Service Bus / VM / SQL / LB 12-month meters rejected as expiring traps; blob + egress discounts are automatic. Adds the standing AI options reference for the future AI RPCs: always-free F0 SKUs per task (Translator, Language, Vision, Content Safety, Document Intelligence, Speech) with the mechanics that make them budget-safe (throttle-not-bill on quota, create directly with F0 — Foundry-provisioned resources default to S0, keyless applies) and the explicit note that generative drafting/image work has no free Azure tier — that is Azure OpenAI or the SaaS keys. (PR #116) -
CodeQL
actionslanguage added to the advanced matrix — the retired Default setup had been scanning workflow files (language:actions); the advanced setup now owns that coverage across the repository's 12 workflows. Context: the Tool status page's erroringlanguage:go/language:java-kotlinentries are stale Default-setup configurations auto-created ~3 weeks ago from stray Go/Java snippet files inside the vendored.claude/harness — the exact paths the advanced config excludes. Those languages are deliberately NOT added to the matrix; the stale configurations are removed operator-side from the Tool status page's ⋯ menu. (PR #115) -
Variable naming standard (workload owner directive, 2026-08-18) — operator-set configuration names are UPPER_SNAKE_CASE, maximum 2 words (3 only to break a real collision), with no provider prefixes:
CLIENT_ID,TENANT_ID,SUBSCRIPTION_ID,RESOURCE_GROUP,APP_HOSTNAME. Contractual names (VITE_*,GITHUB_TOKEN) are exempt. Applied immediately to every workflow-consumed repository variable — all were still unset, so the renames are free:AZURE_CLIENT_ID→CLIENT_ID,AZURE_TENANT_ID→TENANT_ID,AZURE_SUBSCRIPTION_ID→SUBSCRIPTION_ID,AZURE_RESOURCE_GROUP→RESOURCE_GROUP,FUNCTION_APP_HOSTNAME→APP_HOSTNAME(FUNCTIONS_STORAGE_ACCOUNTkeeps its third word to avoid colliding with the content account). The standard is codified in theiac-repo-standardizeragent — which now sweepsvars.*/secrets.*on every standardization run — and in the Wiki IaC-Repository-Standard page; CHECKLIST §7 carries the rule and anAPP_HOSTNAMErow. (PR #114) -
Apply verification for the T-503–T-506 hardening (2026-08-18) — the operator applied the full set in HCP Terraform; cold start passed, verifying the T-503 VNet runtime/package-pull path directly. A post-apply
validate-deployedrun is byte-identical to the pre-apply baseline (no external regression), and Repository Policy / IaC Validation / CI / CodeQL are all green onmain. One verification remains blocked:heal-computed-properties— the probe for T-504's0.0.0.0Azure-datacenter sentinel — fails at Azure login because theAZURE_CLIENT_ID/AZURE_TENANT_ID/AZURE_SUBSCRIPTION_IDrepository variables were never set (a pre-existing gap, failing on every run before the hardening too; now recorded in CHECKLIST §7). Evidence table published as an addendum to the Wiki Resource-Validation-Report; plan v0.2 dispositions moved to APPLIED. (PR #112) -
T-503 — Functions host storage network-restricted (apply pending in HCP Terraform; the last item of the T-50x hardening series). The host storage account moves to default-Deny with three deliberate survivors: the Flex app's runtime/package-pull path (VNet integration + new
Microsoft.Storageservice endpoint on the integration subnet — which also makes the content account's existing subnet rule provably non-inert), a per-run firewall window indeploy-functions.yml(add runner IP → deploy → always-run remove) under a new Storage Account Contributor grant scoped to exactly this account, and operator windows viafunctions_storage_admin_ip_rules. Rollback is one variable:functions_storage_network_default_action = "Allow". The#trivy:ignore:AVD-AZU-0012suppression is deleted — the CI gate now enforces the control it previously excused. New required inputsAZURE_RESOURCE_GROUPandFUNCTIONS_STORAGE_ACCOUNTrecorded in CHECKLIST §7. Verify after apply with a functions deploy and a cold-start invocation. (PR #111) -
T-504/T-505/T-506 — the security and observability remediation ADR-0018 refused to ratify, now implemented in Terraform (apply pending in HCP Terraform). Cosmos hardening (T-504): VNet service firewall allowing the Functions integration subnet (new
Microsoft.AzureCosmosDBservice endpoint), the0.0.0.0Azure-datacenter sentinel so heal-computed-properties keeps working from GitHub-hosted runners (variable-gated to drop later), operator-windowcosmos_admin_ip_rules,local_authentication_disabled(variable, default true), and continuous backup (free 7-day tier). Observability layer (T-505):infra/observability.tfadds theag-hcw-ops-prodaction group and diagnostic settings for Key Vault, Cosmos (the plan's four categories), the content blob service and Azure OpenAI; the budget gains the approved 50/75/90/100 ladder plus a Forecasted-at-100 alert routed through the group; Log Analytics gets the 0.25 GB/day cap. Keyless OpenAI (T-506): custom subdomain (planned replacement of the stateless account + both deployments —openai-client.jshas zero importers, so nothing breaks),local_auth_enabled = false, Cognitive Services OpenAI User for the Function App identity, the primary-key output deleted, and anAZURE_OPENAI_ENDPOINTapp setting for future keyless wiring. Plan v0.2-as-built dispositions updated to "resolved in code, closes on apply". (PR #108) -
Infrastructure plan v0.2-as-built and ADRs 0018–0021 — implements the REVIEW §8.2 decision (workload owner, 2026-08-18) to supersede plan v0.1 with a plan that describes the real system.
.azure/infrastructure-plan.jsonis now version0.2-as-built: every implemented resource with its as-built properties, each deviation from v0.1 dispositioned as either a ratified decision or explicitly-unratified remediation debt (T-503–T-506, purge protection). Four ADRs staged to the Wiki: 0018 (umbrella supersede + disposition table), 0019 (single Function App — supersedes ADR-0004), 0020 (flat native Terraform root module — supersedes ADR-0005's AVM clause, resolves TODO T-502, and rewrites the README AVM guardrail to "pinned versions, stable addresses"), 0021 (Container Apps CI runner ratified as failover-only). ADR register updated; ADR-0004 marked superseded. New TODO T-506 (keyless Azure OpenAI: RBAC grant, delete the key output, disable local auth). (PR #107) -
Resource validation pass, first execution (2026-08-18) — results published as the Wiki Resource-Validation-Report page (staged in
.github/wiki/, linked from Home and the sidebar). External surface: edge live, TLS healthy to 2026-09-28,www/api-azureNXDOMAIN (consistent with same-origin), but Cloudflare bot challenge blocks all datacenter-IP validation of the origin. Plan-vs-code parity: ~40% of the approved plan's resources implemented, with material security-posture deviations (Cosmos open to the internet with key auth on, LRS vs ZRS, purge protection defaulted off, ungated keyed OpenAI) and material never-planned resources (CI runner, 71 containers, model deployments). Follow-ups filed: TODO T-504 (Cosmos hardening), T-505 (observability control layer); human decisions REVIEW §8.1 (Cloudflare synthetic-access rule) and §8.2 (reconcile implementation to plan, or supersede the plan as-built). (PR #106) -
validate-deployed.yml— on-demand deployed-surface validation — aworkflow_dispatchworkflow running the externally observable half of the Deployment Runbook's §4 verification from a GitHub-hosted runner: DNS for the apex/www/api-azurenames, TLS certificate inspection, frontend status + security headers, andscripts/smoke-deployed.mjstier 1 (anonymous, no side effects) against a dispatch-time base URL (defaulthttps://hybridcloudworks.com/api). No secrets or cloud credentials — same doctrine asci.yml; smoke tiers 2–3 remain operator-run. Results land in the job summary. The staged Deployment Runbook §4 references it. (PR #105) -
IaC repository standardization — the repository now carries the baseline governance surface expected of a permanent infrastructure repo:
.github/CONTRIBUTING.md,.github/SECURITY.md,.github/CODEOWNERS, a pull-request template with a Terraform-plan gate, issue templates (including an infrastructure change request with blast-radius and rollback prompts), a root.editorconfig, andinfra/README.mddocumenting layout, working rules, guardrails and the ALZ-absorption posture. The repository policy script allowlists exactly these files; narrative documentation still belongs in the Wiki. A newiac-repo-standardizeragent (.claude/agents/) encodes the standard so future repositories can be brought to the same baseline. - IaC validation gate —
.github/workflows/iac-validate.ymlrunsterraform fmt,terraform validate(viainit -backend=false, so no credentials or state access), tflint (infra/.tflint.hcl) and a Trivy IaC misconfiguration scan on every pull request touchinginfra/**. Until now nothing validated Terraform changes at all while the prototype delivery workflow stayed disabled. prevent_destroyguards on stateful resources — the Cosmos DB account, both storage accounts and the Key Vault now refuse plans that would replace them; removing a guard is itself a reviewed change. Applied together with theterraform fmtdrift that had accumulated inmain.tf.- Deployment Runbook and IaC Repository Standard as wiki-as-code — the
day-1 apply procedure, day-2 operations, ALZ-absorption sequence, and the
standard this repository now conforms to, staged under
.github/wiki/(with updatedHomeand_Sidebar) and published to the GitHub Wiki by the newsync-wiki.ymlworkflow on merge tomain. The workflow overlays staged pages only — unstaged wiki pages remain UI-editable — and uses the built-inGITHUB_TOKEN, so no PAT or additional GitHub App is required. Staged pages become repository-owned: they get PR review like the code they describe. The sidebar's repository links now point at the HybridCloudWorks org instead of the pre-move personal fork.
Changed¶
-
Azure certification detail pages download only their own study-guide outline (#500).
frontend/src/data/azure/study-guides.js(491 KB source) is now one module per guide plusindex.jsunderfrontend/src/data/azure/study-guides/.CertDetailPageloads the exam's module throughimport.meta.globanduse()inside a memoisedSuspenseboundary, so hydration keeps the prerendered outline until the module lands. The Azure detail chunk drops from 74.33 kB to 6.36 kB gzipped (AWS: 2.75 kB), plus about 1.8 kB for the one guide. Prerendered HTML is unchanged: AZ-104 still carries its outline and all five deep links, and all 50 guides render.update-study-guides.mjswrites the directory throughscripts/study-guide-files.mjs. Guide modules carry no date, so an unchanged guide rewrites byte-identical. The weeklyupdate-learn-catalogue.ymlstages and replaces the directory, and detects new guides withgit status --porcelain, since a new file is untracked. -
The admin sidebar stays put, reads cleanly, and calls the newsletter page Newsletter Hub (#566). The admin shell is now exactly one viewport tall: the sidebar is pinned, its menu scrolls on its own when it outgrows the screen, and only the content column (
#admin-main) scrolls, so the ContentForge brand never leaves view on a long page such as New Content. Pinning withstickyalone was measured and does not work on this site:html { overflow-y: scroll }withbody { overflow-x: hidden }makes the body a scroll container that never scrolls, so a sticky aside pins to it rather than to the window. ScrollToTop now also resets#admin-main, so a page opened from the menu starts at the top. The brand block trades its fixedh-14andleading-nonefor a minimum height with padding, which is what clipped "ContentForge" at 125% and 150% zoom. The nested<main>is gone:#admin-mainis a div inside App's<main id="main-content">, so an admin page has one main landmark. The product tags beside menu items (New, Podcast, Publer, Linkie, Resend, VPS) are removed; the Review Queue and Editor count badges stay. "Mailing List" becomes "Newsletter Hub" in the menu, the page header, the Telegram message when the Monday build fails, the builder's "review and approve" message, and the Resend error fallbacks on the page and the API. The route/admin/mailing-listand thecms/mailing-list/*API paths are unchanged, so bookmarks and links keep working. -
The SPA and the API are two app registrations again, and localhost is off the production one (#522, #521). This ADR-0006 decision — "one Entra SPA registration, one API registration" — was accepted, written into
verify-token.jsDECISION 3 andinfra/variables.tf, and then not what the tenant did: the cutover put an SPA platform on the API's own registration. A codebase describing a tenant it does not have is worse than either choice made deliberately.
The divergence was reasoned, not accidental. One registration means the SPA
requests a scope on its own app, which consents automatically and removes the
risk of a client id and an audience that disagree — and this project has paid
for that class of failure twice. That risk is now caught before it ships:
assertDeployConfig refuses a deploy build whose Entra ids are not GUIDs
(#516), which is why this landed after it and not before. The rationale is
kept struck through in 02-entra-spa-client.ps1 rather than deleted,
because the argument was sound and somebody will otherwise re-derive it.
Microsoft's stated reason for separating them is permission inheritance: "if the web API has a higher set of permissions, then the client app doesn't inherit them." One registration is one service principal, so any credential or Graph permission ever added for the API is simultaneously available to a browser-delivered public client.
Exactly one value changes: VITE_ENTRA_CLIENT_ID. ENTRA_API_AUDIENCE
and VITE_ENTRA_API_SCOPE do not — the split changes the client, not the
resource. App-role assignments do not need redoing either: they live on the
API's service principal, and the roles claim rides in the access token
whichever client asked. Both facts are now stated in the places most likely to
be edited by someone assuming otherwise.
01-entra-spa.ps1 is replaced by 01-entra-api.ps1 (the resource, its
preconditions, and the Admin assignment), 02-entra-spa-client.ps1 (the
client) and 03-entra-dev-client.ps1. The client scripts take -Prune, which
replaces the redirect URI list rather than unioning it, and print removals as
loudly as additions — the unioning predecessor could do neither, which is how
http://localhost:5173 survived unnoticed on a production registration.
Localhost now lives on a dev-only registration. cors.js already argued
this case for origins — "any page running on a victim's machine … can make
cross-origin calls to production carrying the victim's token" — and it
applies with more force to a redirect URI, where a process on 127.0.0.1 can
complete an authorization-code redirect for the production client id. CORS
gates its localhost entry on NODE_ENV; an app registration has no such gate,
so the only control is not listing it. The fix had been applied in one place
and left open in the other.
The Static Web App origin stays, and its comment now says why instead of
claiming to be temporary. It said "REMOVE IT when DNS moves"; DNS moved weeks
ago and it is still there, because deploy-azure-frontend.yml documents the
production break-glass as re-pointing the custom domain — which lands on
exactly that hostname. Removing it would take admin sign-in out of the only
documented recovery path, discovered during an incident. The revisit trigger
is now a real one: when the Static Web App is deleted.
A vitest file reads the PowerShell as text and asserts the production scripts list no localhost and nothing plain-http. PowerShell is otherwise untested here, and these are the files that rewrite the tenant.
- MFA is enforced by security defaults, not Conditional Access (#514).
Checked against the live tenant on 2026-09-12: security defaults are enabled
in tenant properties, and Conditional Access is unavailable because the tenant
is not licensed for Entra ID P1.
AdminAuthGuard.jsx,entraAuth.jsand ADR 0006 all stated Conditional Access, which would have sent anyone looking for a policy to an empty blade.
Two consequences are now written down rather than left to be rediscovered: security defaults cannot be scoped or excepted, so a break-glass path has to survive MFA rather than bypass it; and Entra disables security defaults automatically the moment any Conditional Access policy is created — so licensing P1 later is not a free upgrade, it is a change that silently replaces the MFA control.
lib/auth/roles.js has said since it was written that Continuous Access
Evaluation "does not cover us", and now carries the citation for it: CAE
requires that both the client and the resource be CAE-enabled, and CAE-enabled
resources are Microsoft first-party services. Declaring CP1 would take on the
obligation to handle claims challenges while no resource in the call graph can
issue one.
- A deploy build with the Entra variables unset shipped a sign-in pointed at
every tenant (#516).
msalConfig.jsdefaulted the authority tocommonwhenVITE_ENTRA_TENANT_IDwas empty, andvite.config.jsvalidated exactly one variable —VITE_AZURE_FUNCTIONS_URL— so nothing stopped the build.deploy-azure-frontend.ymleven carried a comment acknowledging it.
What that actually produced is worth stating, because it is not what it
sounds like. common was never a route to backend access: the API pins one
tenant by issuer, so a token from another directory could never have been
authorized. What it produced was a SPA that accepted a sign-in from any Entra
tenant or personal Microsoft account, stored that identity in localStorage,
rendered signed-in UI, and then 401'd on every call — a confusing partial
success where an unambiguous failure belonged.
The backend has had this instinct since it was written: verify-token.js
refuses to start without its two settings, and infra/variables.tf rejects an
empty audience because an empty one silently disables audience validation. The
client half never got it, and now has it.
Shape, not presence. A presence check would have caught neither realistic
mistake, because .env.example recommended common for the tenant and
/.default for the scope — both non-empty, both wrong. A GUID test rejects
empty, common, organizations and consumers in one rule, and the error
lists every problem at once so three missing variables do not cost three
deploy attempts. /.default is deliberately still allowed: it is a real
delegated request shape, just not this registration's, and the lever for that
is documentation rather than the build.
REQUIRE_API_BASE keeps its name even though it now gates more than the API
base. Renaming it is fail-open — the deploy workflow is what sets it, so a
rename landing without the matching workflow edit would silently disable every
check with nothing going red.
msalConfig.js now falls back to an all-zero GUID rather than throwing. A
module-scope throw would take out the several test files that import it
transitively without mocking, and would convert a build-time problem into a
runtime crash on a path that already has a working failure mode: MSAL fails at
authority resolution, onAuthStateChanged reports null, and the sign-in card
renders.
Also corrected: .env.example no longer suggests common or /.default, and
functions/local.settings.json.example gained ENTRA_TENANT_ID and
ENTRA_API_AUDIENCE and lost FIREBASE_PROJECT_ID — without the two Entra
settings a local func start could not reach getDefaultGuard() at all,
because createTokenVerifier throws on both.
- The API accepted an ID token as an access token (#515).
functions/src/lib/auth/verify-token.jshas always opened with DECISION 3, which names the hazard precisely: "With a single registration, an ID token minted for the SPA carriesaud = <client-id>— indistinguishable from an access token for the API." The tenant runs one registration —scripts/cutover/01-entra-spa.ps1put an SPA platform on the API's own registration — so the hazard was live rather than hypothetical. An ID token carried the sameaud, the same v2 issuer and the same signing key as an access token, and because Entra emits assigned app roles in both token types it carriedroles: ['Admin']too. Every check the guard made, it passed.
Defence in depth, not an escalation. Both tokens sit in the same browser
cache for the same user, so holding one implies holding the other and there is
no cross-user path. But it was this codebase's own documented invariant,
violated in production, and the second half of DECISION 3 — "reject anything
without a scp/roles claim" — had never been implemented. scp appeared
exactly once in functions/src, in that sentence.
That proposed remedy would not have worked either. roles is in ID tokens,
so a roles-presence check separates nothing. scp is the claim Entra puts in
delegated access tokens and never in ID tokens, and it is now required in
authenticate() — so requireUser endpoints inherit it, which matters because
bootstrapCurrentUserAdmin is reached that way. Agents are unaffected:
require-agent.js runs its own client-credentials path, and now refuses a
token bearing scp so the two guards are disjoint by construction rather than
by the two role names happening to differ.
Alongside it, the verifier now asserts what Microsoft's API-protection
guidance asks for and jsonwebtoken does not check: tid is a GUID and matches
the configured tenant, iss agrees with that tid, and ver is 2.0. The
v1 sts.windows.net issuer is gone — with a bare-GUID audience it could never
pass, but infra/variables.tf documents the api:// audience as a supported
alternative, and the day someone took that option the v1 issuer would have
gone live with no version gate behind it. The assertions run inside the
jwt.verify callback, so every claim they read has already had its signature
checked and tid reaching an audit row is not attacker-controlled.
Before deploying, confirm scp is in the live token. Admin → Health lists
the claims present in the caller's own token; scp must be among them. If it
is not, every admin call returns 401 with a missing-scope row in
admin_audit_logs — a clean, attributable failure, and one revert away.
- An expired token told the owner he was not authorized, and offered to
re-provision him (#503). Opening
/admin/integrationson 2026-09-11 showed Access Denied — spatino@hybridcloudworks.com is not authorized with a Bootstrap My Admin Access button. Signing out and back in "fixed" it, which is the tell: the problem was never authorization. Theadmins/{oid}record was correct the whole time.
Three outcomes were returning one value. fetchAdminStatusFromBackend
answered { isAdmin: false } for a rejected token, for a call that never
completed, and for a server that genuinely said no. Only the third is an
authorization answer; the first two are a check that could not run, reported
as a check that ran and refused. The warning string it logged —
'Not an admin or token expired' — admitted on the line above that it could
not tell which. That is the same defect class as unknown versus
disconnected on the Recording Hub and the 401-versus-403 saga that cost
#358 two days.
The hook now returns a discriminated accessState — authorized,
unauthorized, unknown — and unknown is never cached, so a transient
failure does not sit in front of the portal for five minutes. authedFetch
keeps the HTTP status on the error it throws and acquireApiToken tags
every failure with the one action that recovers it, which is what makes the
three distinguishable at all: requireUser on the server denies only with
401, so a non-200 from this route is never a verdict about the caller.
What the owner sees now. An expired session redirects to Entra and comes back signed in, without a denial and without anything to click — once per tab, because a second automatic attempt against a genuinely broken configuration is an infinite redirect loop with no screen to read. After that it is an honest "Could not verify your access" card with a Sign in again button; an unreachable API gets the same card with Try again. Access Denied is now reached only by a real 200 saying so.
And the bootstrap button is no longer guessed at. getCurrentAdminStatus
returns canBootstrap, the server running the same three-way gate the POST
enforces, so the button appears only when pressing it would succeed — never
for an account that already holds a registry row, active or deactivated.
Offering a deactivated admin a self-promotion to super_admin is the
opposite of what the deactivation meant.
- The Monday refresh put the six GitHub exams straight back into the Azure catalogue, hours after #507 removed them (#496). Found by dispatching the workflow #499 had just widened — run 34627119281 on 2026-09-11 opened a pull request re-adding GH-100, GH-200, GH-300, GH-500, GH-600 and GH-900, with their six study-guide outlines behind them, +1167 lines of them.
Nothing was broken; the decision was simply in a place no script reads.
GitHub certification moved onto Microsoft Learn, so all six sit on the
Microsoft Certifications poster and in the credentials browse API — both of
which are this updater's sources. It had always been offering them, and
before #496 the catalogue accepted them. #496 recorded the removal in the
catalogue's file header and in a cross-catalogue test, and
update-applied-skills.mjs reads neither. So the rule now lives where the
automation meets it: the script refuses any exam another catalogue owns,
before building the row.
A prefix, not the six codes. GitHub adding a GH-700 would recreate the
duplication exactly, and it belongs to /github/education for the same
reason the six do — the Azure LEVEL_META has no Professional rung, so a
GitHub exam cannot be stated truthfully there whatever is typed. Refusals are
reported in the pull request body under their own heading rather than dropped
silently, and deliberately not in the "not added" bucket, which means "nobody
could confirm this exam" and would send a reviewer hunting a source problem
that does not exist.
The workflow also checked the wrong thing. Its test step ran
azure/certifications.test.js alone, which by construction cannot see a
contradiction with another catalogue — so the run went green and left the
defect for the pull request's CI to catch. It now runs
education-catalogues.test.js too, where the cross-catalogue guard lives,
so the workflow refuses to open that pull request itself.
Worth recording that the guard worked: one exam, one catalogue (#496) is
what failed on the bad pull request, on the same day it was written. The
regression was caught by CI, not by a reader — but by the pull request's CI
rather than the workflow's, which is the gap closed here.
- The three dated catalogue rows were settled without waiting three weeks for them (#494). #494 held four obligations that all read as "come back after 2026-09-30". Three of them did not need the wait, and answering them now turned up two things the issue had wrong.
AZ-800 was missing from the issue's own table. It carries the same
expiryDate: '2026-09-30' and the same replacedBy: 'az-802' as AZ-801, so
it trips in the same breath — four rows go stale on 2026-10-01 across three
catalogues, not the one the issue expected a person to fix. A test now pins
the exact list, so the next dated row is a named expectation rather than a
surprise.
The pages were never going to lie, which changes what the alarm is for.
deriveStatus reads the dates at render time, so on 2026-10-01 AZ-800 and
AZ-801 render retired and PAA renders active with nobody touching a file.
No visitor is shown a retired exam as testable. What goes wrong on that day
is narrower and worth stating precisely: a stored status its own dates have
overtaken is a claim nobody has rechecked against the vendor. Pinned at a
fixed future date so it is known rather than discovered.
Whether the Monday run self-heals AZ-801 was answerable today, and the
answer is "in one of three cases". #494 recorded it as "an expectation, not
a guarantee — nobody has watched that workflow correct a row yet", but
reconcileLifecycle never reads the calendar: it compares the file's status
against the source's by LIFECYCLE_RANK, so what the 2026-10-05 run will do
is decided entirely by what Microsoft reports, and every branch was
exercisable immediately. It retires the row when Microsoft reports retired
on the same date. It does not when Microsoft reports a different
retirement date — the date conflict is caught before rank and the file wins,
leaving the row stale with one report line as the only clue. It does not
when Microsoft is slow and still reports expiring, and that case reports
nothing at all. All three are rule 3 working as designed, and none of them
was covered: no test anywhere fed the reconciler a source saying retired,
so the forward move the issue was waiting on had never been exercised.
AZ-802 closes the Azure half outright (owner, 2026-09-11): it becomes the
single test replacing both AZ-800 and AZ-801. That is what makes those two
rows need no vendor round-trip on 2026-10-01 — the successor is already known
and already in the file, which certifications.test.js has asserted since
#464. The gap was that nothing said the successor is itself undated: AZ-802
is active and carries no dated field at all, so findStaleStatuses can
never name it and it is not a third row to revisit later. Now pinned, and it
fails the day Microsoft puts a date on AZ-802 — which is the right moment to
look, rather than meeting a dated successor as a red build months later with
no record of why anyone expected otherwise.
MLA-C02 confirmed a false positive, as #494 argued: dateProblems reads
only betaEndDate and gaDate for a beta row, never betaStartDate. Pinned
both ways — silent on 2026-10-01, and firing on 2027-01-15 once its gaDate
is reached — so the reasoning is enforced rather than restated in prose the
next time somebody sorts by date and panics.
PAA was re-read against Google on 2026-09-11 and nothing had moved:
still "open until September 30", three hours, ~80 questions, $120 against
$200 retail, English, one-year validity, still no GA date. DATA_AS_OF is
deliberately not bumped — one row was re-read, not all fifteen credentials,
and that field is a claim about the whole file.
The one thing no day before 2026-10-01 can supply is what the credential
becomes, because the post-beta state does not exist yet. That obligation is
carried by the alarm rather than by the board: on 2026-10-01 the suite fails
naming PAA and pointing at DATA_SOURCE, which is the instruction the ticket
held, delivered on the day it becomes actionable.
- Every certification catalogue re-verified against its vendor, ahead of the
2026-09-29 deadline (#469). Seven catalogues were checked row by row
against the official source and
DATA_AS_OFmoved to 2026-09-10. Azure is excluded becauseupdate-learn-catalogue.ymlrefreshes it on a Monday cron, which was confirmed sound rather than assumed.
One real error, nine months old. The AWS row called itself SCS-C02
while already linking SCS-C03's exam guide, and nothing had ever compared
the two. SCS-C02 stopped being the current exam on 2025-12-01. The row is
now SCS-C03 with previousSlugs: ['scs-c02'] so minted links still
resolve, following the SOA-C02 precedent, and the three places that
hard-coded the old code outside the catalogue were updated with it —
aws/LandingPage.jsx and two entries in MicrocredentialDetailPage.jsx,
which would otherwise have displayed a superseded exam beside a corrected
one.
Three more corrections, each from the vendor's own page. VMware's VCTA
is gone from Broadcom's index entirely and is now retired — with no
retiredDate and no replacement, because Broadcom publishes neither and
inventing them is the failure this repository keeps legislating against.
Red Hat renamed RHCE to "Red Hat Certified Engineer in Ansible", and the
ex374- URL the file carried now 404s. The FinOps Professional exam
requires three prerequisite certifications rather than two, confirmed in
two places on the Foundation's own site.
GCP, GitHub and HashiCorp needed no data changes at all, and two
near-misses are worth more than the diff would suggest. A cached copy of
GH-100's page still says "(beta)"; the live page does not, so trusting the
cache would have flipped a good row to beta. And GH-500 is absent from
learn.github.com/credentials while its own page is live and its study
guide took a revision in July 2026 — an omission from a marketing page is
not a retirement. Both are now recorded in the file headers as sources NOT
to trust, which is the more durable half of the work.
What was deliberately not done. Broadcom lists thirteen VCF
certifications against the six this catalogue carries. The titles and exam
codes are recorded in the file header, but no rows were added: hours and
prepTime are this site's own study estimates, and fabricating fourteen of
them is exactly the confidently-wrong entry the rule exists to prevent.
AWS's successRate percentages have the same problem and are flagged for a
decision — every other field in these catalogues is sourced; that one never
was.
A clock trap worth recording. todayIso() builds its string from
getFullYear/getMonth/getDate, so the freshness check runs on the LOCAL
calendar. On a machine in CDT at 23:11, DATA_AS_OF = '2026-09-11' — the
correct UTC date — fails six tests as "in the future", while a UTC CI runner
would accept it. The date used is the one the repository's own clock agrees
with.
- Every timer now runs on UTC; nine live jobs moved five hours earlier
(#416). Owner decision: all times in this app are UTC.
WEBSITE_TIME_ZONE = America/Chicagowas set app-wide on the Function App, so a bare NCRONTAB hour meant Central — and six places ininfra/observability.tf,infra/variables.tf,infra/functionapp.tfand ADR 0028 said the Cosmos exporter ran at "03:00 UTC" when it ran at 08:00 UTC in summer and 09:00 in winter. The most expensive of the six is thealert-cosmos-export-dailydescription, which is the sentence that arrives in the alert mail: an operator reading it at 05:00 UTC would have gone looking for a run that was not due for another three hours. The issue offered correcting the prose or correcting the clock; the owner chose the clock, so those six sentences became true rather than being rewritten.
The setting was removed, not set to "UTC". UTC is already the platform
default — "The default time zone used with the CRON expressions is
Coordinated Universal Time (UTC)" — and Microsoft documents both
WEBSITE_TIME_ZONE and TZ as unsupported on the plan this app runs on:
"aren't currently supported when running on Linux in a Flex Consumption or
Consumption plan … can create SSL-related issues and cause metrics to stop
working for your app" (Learn, Timer trigger for Azure Functions, retrieved
2026-09-07). Setting it to "UTC" would have kept an unsupported setting on
an unsupported plan in exchange for nothing. Nothing in functions/src
formats a local time for a human — every date it derives comes from
toISOString() or a getUTC* method — so the app clock reached NCRONTAB
and the host's own log stamps and nothing else.
scrapeSkillsHubRss is the one schedule that changed, so that it would
NOT move. Its upstream schedule was every friday 09:00 in UTC and it was
ported as 0 0 4 * * 5 because 04:00 Central is 09:00 UTC. Left alone it
would have retimed the one job whose intent was already UTC; it is now
0 0 9 * * 5, the same instant, and no longer an hour late every winter.
The nine fixed-hour jobs — cleanupTempStorage, cosmosExportScheduler,
forgeScheduled, cleanupRejectedContent, cleanupUnusedCertImages,
generateReviewerDigest, checkLiveLinks, reVerifyCertifications — all
moved five hours earlier in absolute time (six once CST starts), and the
six hour-interval timers kept their cadence and shifted phase. The four
minute-interval timers did not move at all. Migration-Plan §4.2 carries the
per-timer table of what moved, appended rather than rewritten.
functions/src/functions/timer-schedules-utc.test.js is the new guard. It
reads the real app.timer() registrations rather than the source text,
fails if WEBSITE_TIME_ZONE or TZ reappears in infra/*.tf, computes
which schedules depend on the app clock instead of trusting a list, and
requires each of them to carry the UTC time it is meant to mean.
scripts/cutover/05-verify-timer.ps1 printed "04:00:00+00:00 means
WEBSITE_TIME_ZONE is NOT being applied", which is now exactly backwards;
it prints the inverse, and says so.
- A publisher can give a published article the right slug, from the row where
they already look at its URL (#400). Publisher, not editor: this changes
the URL a live article is served at, which is the same authority
publishContentand the #374 re-host backfill require, and the reasonupdateContentItem— an editor route — no longer accepts a slug at all.
#403 made new collisions impossible and made a republish move an article off a slug another document holds. It could not give an article the RIGHT slug, and three articles need exactly that: they share one title, so they were published onto one URL and two of them are unreachable. Nothing in the admin UI edited a slug, so the only fix was a script that would leave nothing behind for the next time.
The generic route was checked first, and it is what made a dedicated one
necessary. updateContentItem accepted a slug — slug was on no denylist
and matched none of its normalizers, so it fell through to the generic string
branch and was stored EXACTLY as sent: { slug: ' Hello World!! ' }
round-tripped with its spaces, its capitals and its punctuation intact, and
nothing asked whether another document already held the value. Both halves of
that are how three articles ended up on one URL, so the route is fixed rather
than worked around: slug and Slug join FORBIDDEN_CONTENT_UPDATE_KEYS,
and a site URL now has exactly one writer.
That writer is POST /api/cms/content/slug (publisher), a second narrow
reason on the publish pipeline beside the #374 re-host — the extension point
REHOST_IMAGES_REASON's own comment anticipated. It slugifys the input so
an operator cannot type a value the pipeline would never produce, probes
c.slug OR c.Slug through the same never-throwing slugHolders wrapper an
ordinary publish uses, and
REFUSES a slug another document holds rather than suffixing it — the one
place a set-slug has to be stricter than a publish, because silently giving
an operator a different URL than the one they asked for is how a URL
correction becomes the next URL surprise. An unanswered probe is likewise a
refusal — not because it probes differently, but because evaluateSlugChange
reads that wrapper's null as a refusal where resolveSlug reads it as "not
established". Same probe, opposite reading, and for the same reason read the
other way round: a publish must not lose an article's URL to a transient
query error, but a NEW slug assigned on an unverified probe is how one URL
gets two documents.
It is narrow because a full republish does not merely do more than these
articles need — it fails. They arrived from Site-Main already published and
have never been through this pipeline's gates: the one still serving the
contested URL scores 68 against a threshold of 82 (238 words, no inline
modules) and carries no hero image, so the quality gate refuses it and the
image gate refuses it after that, and a set-slug built on the full path would
write a failed quality report onto the article and leave its URL exactly as
broken as it found it. So the branch writes the cased slug pair and
curatedSubpagePath / slugPageUrl / publishedUrl / publicUrl — derived
through resolveCuratedSubpagePath and toPublicUrl, so a corrected URL and
a published URL can never be built by different rules — in ONE patch
conditioned on the read's ETag, and nothing else: no gates, no cover or
social trigger, no dates, no forge stats, no Live rewrite, no status
change. One patch rather than two steps so there is no window in which an
article holds a new slug at its old URL.
It refuses rather than half-completing, in two places. A slug another
document holds is refused rather than suffixed — and so is an article whose
published path cannot be computed at all, meaning it has neither a stored
curatedSubpagePath nor a provider recognisable in any of the five fields
resolvePublishContext reads. Writing the slug for such an article and no
URLs would leave the panel reporting a move beside a link still pointing at
the old URL, which is precisely the state this route was built to remove.
Both are a 409 naming what would fix them. That diverges from the ordinary
publish write, which spreads the path conditionally, and the divergence is
the point: a publish is best-effort on a document being published, where a
missing path is one incomplete field among many, while a set-slug is a
surgical correction to a URL, and one that cannot compute the URL should
stop and say so.
Both cased fields are written, to one value. The probe reads
c.slug OR c.Slug, so a document holds every distinct value across the pair:
writing only slug would leave the article still holding its old URL in
Slug, blocking any other article that legitimately wants it, and would
leave the pair divergent — the exact shape, on ten of the twenty-two
published articles, that hid #400.
The control is on Publish, inside the row of the already-live article
being fixed, beside the "View Live" link it changes. It offers the article's
own Slug field as the suggested value, because for this class of article
that field is the source publisher's slug and therefore the only surviving
record of what the article actually is. Normalisation stays server-side —
slugify lives in functions/ and there is no package shared with
frontend/, so a live client-side preview would mean a second copy of the
one function whose output IS the URL — and the response names requested
beside slug, which the panel shows whenever they differ. Nothing can
silently no-op, and the panel distinguishes four outcomes rather than
collapsing them: a move (the URL is a different one now), a repair (the slug
stayed, but Slug was brought into line with it or URL fields that had
drifted were re-derived — a real write, and one of the cases this exists
for), a truthful no-op, and a refusal that wrote nothing. The response
carries moved and the list of fields the patch touched so the panel is
told which happened rather than guessing, and a refusal names the document
holding the URL.
- The site has a show of its own, and every audio page leads with it
(#349). admin_config/podcast_feeds could only describe feeds that belong
to a provider — every row is { provider, url } — so the RSS.com show the
owner created, Hybrid Cloud Insights, had nowhere to go: seeding it under one
provider would have hidden it from the other seven pages and mislabelled it
on the eighth. The document grows one field, mainFeedUrl, above the
provider rows and visibly the primary one on Admin → Platform settings; the
provider rows stay as optional extras. A document stored today normalizes
unchanged and a Functions revision predating the field ignores it, so the
merge and the deploy can happen in either order.
Inside a run the show is an ordinary feed entry under the reserved provider
main, returned first by resolvePodcastFeeds, so the ingest loop, the
dedupe, the 410 handling and the summary line work on it unchanged and the
episodes it writes carry provider: 'main'. A reserved value rather than an
absent one because every consumer of podcasts already keys on that field —
the container's composite index, the public list's SQL filter, the section
counts — and a row with no provider would be fetched by no query and counted
by nothing.
GET /api/public/podcasts?provider= now filters
c.provider IN (@provider, @mainProvider): one query, so a show episode
arrives once, appears on every provider's page and is duplicated on none. The
page orders the show first as a group, so the featured player on every
provider page plays the show's newest episode; the source filter is built
from the sources a page actually has rather than a fixed pair; and the RSS
subscribe button falls back from the provider's feed to the show, since a
reader subscribing from a page with no provider feed is looking at the show.
Two writes are refused rather than resolved — main as a provider row, and a
provider row repeating the main feed's URL, which would have both ingests
build the same episode ids and flip an episode's provider every two hours.
The sitemap follows. countPodcastDocs counts a show episode for every
provider's audio and audio-architecture, by name rather than through
_unattributed — that bucket means "we do not know which pages this
reaches", and here we do. So the sixteen audio URLs #404 dropped return the
moment one show episode is ingested and the manifest is refreshed; a page
with content that is not advertised is the mirror image of the bug #373
fixed. Recorded in ADR 0029 §1a, which the hosting decision it amends
already owned.
- Thirty-three empty section pages leave the sitemap, and the counts that
decide it now come from every container a section page reads (#373). Part 2
could speak for one section:
frameworksreads thecontentcontainer and the manifest builder was handed that corpus. Every other section reads something the builder cannot see —blog,coder-cornerandcodefall back to the legacyblogscontainer whencontenthas nothing for a provider, which is precisely the case being declared empty, andaudioandaudio-architecturereadpodcastsandlisten_and_learn_episodes, which are not content at all. So the counting moved into the app, where the Cosmos access already is:GET /api/public/content-manifestreturns asectionsmap besideitems, computed over all four containers under the same public filterGET /api/public/contentapplies, and the builder prefers it — falling back to its own frameworks-only count when the deployed revision predates the field, so merging this and deploying Functions can happen in either order without the five frameworks pages returning to the sitemap in between.sitemapRoutesin the pre-render is unchanged: it never named a section, so it drops the new ones the moment the counts arrive. Measured against the live containers and the 2026-09-07 crawl, the sitemap goes from 113 URLs to 80, and all thirty-three that leave are pages the crawl calls empty; nothing the crawl callsworksis dropped. Two rules are the reason a count is trusted: only a section whose page infers a provider from titles and URLs (blog,frameworks) lets an unattributable item block its zeros — the rest match a stored provider field in SQL, so such an item is fetched by no page and blocking sixteen audio pages on behalf of two provider-less podcast rows would be wrong — and a read that fills its window returns null rather than a truncated count, because the rows past the window are indistinguishable from rows that do not exist.architecture-designsis deliberately not counted: its pages mergestaticBlueprintshardcoded in eachArchitecturePage.jsx, so four of the five render fine on a corpus holding one architecture document and an API zero would drop working URLs. Every dropped route still renders and still serves its empty state; this changes what is advertised, not what exists. - Every publish now checks whether another article already holds its URL,
and the collisions already in the corpus have a report of their own
(#400). One case is deliberately left open and is named below: two
first-publishes racing on the same title can still both probe clean and take
the same bare slug. Closing it would mean suffixing every new URL with a
document id, which is not worth doing to a site's whole URL space for a
collision that is rare, recoverable and now reported. Three published
articles share
enable-ai-powered-discovery-of-azure-updates-with-microsoft-release-communicatio, so two of them are published with no URL at all and the third's identity changes with the order Cosmos returns rows — visible in #399's diff, where the body under that key moved from7ZCmiOEIlWMg99xQHY0Ato1k5ayjbEdYdo7NzvXIWW. None of the three was ever assigned that slug by this pipeline:processPublishContentwritesslugandSlugto the same value in one patch, and on all three — and on ten of the twenty-two published articles — the two differ. They arrived from Site-Main alreadycontentStatus: published, which takes the branch that reuses the stored slug, and that branch never probed.uniqueSlugbecomes pureresolveSlugplus aslugHoldersprobe, keeping its shape — bare slug when nothing else holds it, suffixed with the document id when something does, and a lookup failure never blocking a publish — with both gaps closed: the probe now readsc.slug OR c.Slug, because the two fields carry different values on ten of the twenty-two published articles and the manifest routes onslug || Slug; and every publish probes, a republish included, moving off a slug another document holds instead of re-asserting it. A probe that throws still yields the always-unique suffixed slug on a first publish and now leaves a live URL untouched on a republish. What a read cannot establish — two publishers racing on one title — is documented atresolveSlugand caught by the newscripts/report-slug-collisions.mjs, which prints the contested URLs from the committed manifest with no credential.curatedSubpagePathmoves with the slug now (resolveCuratedSubpagePath, found by Copilot's review): it was taken from the stored value unconditionally, so an article leaving a contested slug would have kept advertising the contested URL inslugPageUrl,publishedUrlandpublicUrlwhileslugsaid otherwise, and it is normalised to a leading slash because three frontend hooks read the provider out ofcuratedSubpagePath.split('/')[1]. The slug is normalised once before the probe for the same reason the URL is:resolveSlugtrimmed whileslugHoldersprobed the raw value, so a stored' shared-slug '— migrated or hand-edited, which is this corpus — matched no holder and the trimmed form was then written onto a URL another article held. - The Social Hub lists Publer accounts again, and when it cannot it says
which of three things is wrong (#397). Both of its call sites tested the
publerProxyresponse withArray.isArray, and the proxy answers with an envelope —{ ok, status, data }, or{ ok: false, code: 'INTEGRATION_NOT_CONFIGURED' }when no key is seeded — which is never an array. So the check was always false and a fully connected workspace rendered exactly like an empty one, on the Compose tab and on Connection Settings both. The reader the Platform settings page grew for the same bug (#391) has moved tofrontend/src/lib/publerAccounts.jswith its unit tests, and both pages import it rather than keeping two copies to drift apart. The three outcomes are now distinct where they used to be one empty list: an unseeded integration says so and names the Connection Settings tab, a failed call says the call failed and carries the upstream message, and only a proxy that answered with no accounts still reads as an empty workspace. The reader names that third case explicitly, because the proxy answers HTTP 200 whatever happens: Publer refusing the key comes back as a resolved{ ok: false, status: 401 }, which no.catch()will ever see, so both pages showed an authentication failure as an empty workspace untilunwrapPublerAccountsgrew afailedoutcome carrying Publer's own status. The unconfigured copy no longer blames the API key either — one code covers a missingPUBLER_API_KEYand a missingPUBLER_WORKSPACE_ID, so the page repeats whichever the server named rather than guessing. Reaching Connection Settings had also been throwing aReferenceErrorbefore it could paint — its two credential tiles calledpublerKey()andpublerWsId(), which are defined nowhere in the bundle and cannot be, because FINDING-04 moved both values into Key Vault. One tile replaces them and reports the only thing the browser can observe: whether the proxy could use the credential. - The audit stops calling a cancelled request a failure, the Azure
architecture page names its provider, and the 2026-09-07 crawl is on the
record (#361, #371, #373, #374). The run against the deployed batch reads
70 works / 40 empty / 3 defect over 113 sitemap URLs, from 68 / 48 / 2 over
118: the eight empty frameworks pages left the sitemap and the repeated blog
URL appears once. Of the three defects one was the crawler's own: Chromium
reports a request the browser cancelled (
net::ERR_ABORTED) exactly as it reports one the network failed, which made the home page a defect for a health route that answered 200 when asked directly./azure/architecture-designshad kept the title it was copied with and never said "Azure". The third is real and stays open on #374: the article's<img>points at an.mp4, which re-hosting will store but cannot make render. - The content-manifest job can actually open its pull request (found by the
first run that had a change to push).
actions/checkoutpersists the job'sGITHUB_TOKENas anhttp.extraheaderin.git/config, and that header outranks a token embedded in the remote URL, so the App installation token the job mints was never the credential git used. The push authenticated asgithub-actions[bot], which holdscontents: readin that job by design, and the remote answered 403.persist-credentials: falseon the checkout leaves the App token as the only credential. The job had reported success on every run since it was rewritten to open a pull request, because the published set had not changed since 2026-08-23 and the push step never ran. - The podcast page is the one audio surface, and the media route serves
byte ranges (#349, ADR 0029). A provider's podcast page now lists the
host's ingested episodes and the published Listen & Learn episodes for
that provider in one date-sorted list, each row labelled with its source,
played by one component with a keyboard-operable seek slider. The aws,
azure and gcp copies of the page (the shared page with colours inlined)
are gone; every provider routes to the shared page and the coverage test
demands a metadata row for each. New
GET /api/public/listen-and-learn/ episodes?platform=returns every approved episode with audio across a provider's certifications, projected to a listing allowlist (no transcript) and joined to its certification;GET /api/public/podcastsnow carriesfeedUrlfromadmin_config/podcast_feeds— the document the ingest timer reads — so the RSS subscribe button and the ingest cannot name two different feeds.GET|HEAD /api/public/media/…honours a singleRange: bytes=…with206andContent-Range, reading only the requested bytes through the SDK's ranged download; a start past the end answers416; every success carriesAccept-Ranges: bytes; caching is unchanged. Seeking into a Listen & Learn episode no longer downloads the whole file, and the range support is what a self-hosted feed would need from an enclosure host. ADR 0029 records the owner's no-paid-services decisions: RSS.com Free with manual upload and feed ingest, Gemini then Azure AI Speech with ElevenLabs deferred, StreamYard as a manual studio with the feed as the boundary. Nothing named after the previous host remains infrontend/. - The two live checks that need an admin's token run from Admin →
Diagnostics (#355, #356). Both issues had the owner copying a bearer
token out of developer tools and decoding it by hand.
/admin/diagnosticsdoes the checks in the signed-in session: it acquires the access token the way every API call does, decodes it locally, and keeps only claim names plusaud,rolesandexp— the token,oid,suband email never reach React state, the DOM or the clipboard, and the registry comparison is a boolean computed in the browser. The values it compares against come from a newGET /api/getAuthExpectations(admin-identity-http.js), which returns the audience and tenant the verifier is configured with and the App Role value the guard checks, so the page tests the API as deployed rather than a constant built into the frontend; it sits behindrequireUserrather thanrequireRolebecause the caller being diagnosed may hold the role and be missing from the registry, and it discloses nothing a valid token does not already carry. The Labs panel submits oneshell-echojob exactly as the Labs console does, reads thelab_jobsdocument back and cancels it so nothing is left queued for the next agent, then judges the result against the documented{ jobId, type, status: 'queued' }response; a second button repeats the request through a plainfetchwith no Authorization header and passes only on 401/403. Copy report puts a Markdown summary — names, booleans, statuses and job ids — on the clipboard for the closing comments on #355 and #356. Registered in.azure/api-surface.json; handler and page tests beside each module. - Three hand-seeded
admin_configdocuments get an admin screen (#351, #352, and the feed list from #348/#349). Admin → Platform Settings (/admin/platform) reads and writesdefault_heroes(the per-provider cover a post gets when AI generation is off or fails),social_autopost(the enabled switch, the Publer accounts and the undo-window delay the publish trigger schedules against) andpodcast_feeds(one RSS URL per provider for the two-hourly timer). Until now each could only be written by an operator holding a Cosmos data-plane role, typing the JSON by hand. The newGET|PUT /api/cms/platform-settings/{setting}(editor) normalizes every save to exactly the shape its consumer reads — hero keys resolve to the eight canonical providers and values must be a same-origin path or https URL,enabledmust be a real boolean and the delay a whole number of minutes, podcast rows pass the timer's ownisValidFeedEntry— and refuses unknown keys rather than dropping them, so the document can no longer drift from the code. A document seeded by hand that does not validate is shown empty with the reason, and saving replaces it. "Use bundled defaults" fills the covers with/images/default-heroes/<provider>.png; the Social card offers the accounts the Social Hub already lists from Publer, with a free-text id as the fallback. Every save is audited asplatform_setting_updatedwith counts only: the route answers with the normalized document, as the page needs it to, but no log line or audit row ever carries document contents. - The sixteen already-published articles can be re-hosted from the Publish
page (#374, the backfill).
Admin → Publishgains an Images: re-host hotlinked action: it lists every published article whose body fields still reference a third-party image — counts, hosts and the last run, never a body — with each one selected, and "Re-host selected" runs them through the publish pipeline in batches of five, tabulating rewritten and failed counts and the failed hosts per article. Two routes back it,GET|POST /api/cms/content/rehost-images(editor to list, publisher to run, up to 25 ids a request). The write isprocessPublishContentunder a newreason: 'rehost-images', which a plain republish of a live article would not have been safe to stand in for: that path re-runs the gates, re-arms cover generation on an article without a cover and the social caption on one that never posted, and rewritesLiveand the publish dates. Under the reason the patch is the rewritten body field(s), theinlineImagessummary andupdatedAt, conditioned on the ETag, with arehost-imagesversion row — and nothing else. Pinned by tests that put the same live article through both paths. - Cosmos exporter infrastructure, inert until armed (#231, ADR 0028). The
Azure half of the out-of-account export: a private
cosmos-exportcontainer on the RA-GRS content account, a lifecycle rule that deletes itsfull/anddelta/run blobs 35 days after creation (five weekly fulls plus their deltas, about 12 GB; thestate/change-feed tokens are never under it), aFEATURE_FLAG_COSMOS_EXPORTapp setting on the Function App, and two scheduled-query alert rules that fire on the absence of acosmosExportCompletedcustom event — one daily for any completed run in the trailing two days, one on Mondays for the Sunday full. All of it hangs on one new workspace variable,cosmos_export_enabled, defaultfalse: the merge applies an empty container, a lifecycle rule and a"false"setting and creates no alert rule, so nothing runs and nothing can page until the owner sets the variable in HCP Terraform and confirms that second run. The rules are shaped around two Azure limits met while writing them: a log alert's query time range is capped at two days, so "no full in eight days" became "no full on the Sunday just gone", checked each Monday; and a rule evaluated daily cannot be stateful, so both are stateless and their descriptions say what that means. No new role assignment — the Function App identity already holds Storage Blob Data Contributor on the account and Cosmos Built-in Data Contributor on the database account. - Landing hero rotations and default covers are generated art (#371, #351).
The four landing pages whose hero sets never existed (
/gcp,/github,/terraform,/finops, twenty 404s per the audit) get their five images each underfrontend/public/images/<provider>-hero/, and the eight default social covers #351 needs land atfrontend/public/images/default-heroes/{azure,aws,gcp,github,terraform,ansible,vmware,multi}.png(1200×630). Nothing was bought and nothing is stock: a new dev script,frontend/scripts/generate-brand-art.mjs(npm run art:generate), draws each file from an SVG template — five distinct compositions per set (diagonal bands, concentric rings, isometric cubes, a node graph, layered waves) in the provider palette fromsrc/index.css, with the provider name as low-contrast typography in the site's own Goldman face — and rasterizes it with@resvg/resvg-js, a devDependency only. Rendering is seeded and uses no system font, so the same script yields byte-identical PNGs on any machine (--checkproves it against the committed files). Every PNG is 1155×924 8-bit RGBA likeazure-hero/and under 240 KB. The emptied hero arrays from PR #380 are restored;hero-assets-exist.test.jsstays green, andgenerate-brand-art.test.jspins each committed file's dimensions, colour type and size. The owner can replace any file one-for-one with real artwork; nothing keys on it being generated, and the generator is not part of the build. - The Cosmos out-of-account exporter, as ADR 0028 designed it (issue #231,
application half). A daily timer,
cosmosExportScheduler, decides full (Sunday) or delta from the UTC date and enqueues onecosmos-export-containerjob per exported container onto the existing platform jobs queue; the worker pagesSELECT * FROM c(full) or reads the change feed from the stored continuation (delta, latest-version mode, whole container) and streams the documents as gzip NDJSON into the privatecosmos-exportblob container at the Cool tier, writing the continuation only after the blob is safely written and a full run's checkpoint taken before it starts paging. Each job ends with a marker; the worker that finds every marker writes the run manifest once and emits thecosmosExportCompletedcustom event the missing-run alerts count. The classification is code —functions/src/lib/backup/export-classification.js, the ADR's six classes — and a test holds it toinfra/cosmos-containers.json: every one of the 72 provisioned containers exactly once, nothing unprovisioned, 60 on a full and 53 on a delta. Logs and telemetry carry container names, counts and durations, never a document. Inert untilFEATURE_FLAG_COSMOS_EXPORTistrue(or1), which Terraform writes fromcosmos_export_enabled; the container, lifecycle rule, alert rules and setting are the Terraform half. The app gains its first custom-event emitter (lib/telemetry.js, the edge probe's envelope shape, no SDK), anduploadBlobFromStreamaccepts an access tier and metadata. A restore runbook (docs/runbooks/cosmos-restore.md) gives the quarterly drill from ADR 0028 §6 as paste-ready PowerShell, andscripts/restore-cosmos-export.mjsreads the latest complete full plus its deltas from the container and upserts them into a target account, with--dry-runthat only counts and--verifythat counts the target back. - Article bodies stop hotlinking upstream images (#374). At publish time
every external
<img>orURL in any body field (blogDraft,Content,content— the audited article kept an RSS stub in one and its eleven images in another) is fetched through the guarded fetcher the cover mirror already uses, stored under the article id in the publiccoverscontainer and rewritten to the site's/api/public/media/…path; the version snapshot carries the rewritten body. A URL that cannot be fetched is left exactly as it was and named on the document in a newinlineImagessummary (fields, rewritten, failed, failedUrls, at), and no failure of this step can fail a publish. Wired into the HTTP publish, the Telegram approve job and the scheduled publisher; a republish re-hosts an already-published article.scripts/scan-inline-images.mjsreads the committed content manifest and lists the published articles still carrying third-party image URLs, so the size of the class is a command away. - Section pages with nothing published leave the sitemap (#373, part 2).
The content manifest now carries
type(the field the section pages select on, already public through the content list) and asectionsmap counting framework items per provider, with an_unattributedrow for items whose provider the page would have to infer. The pre-render omits/<provider>/frameworksfromsitemap.xmlwhen its count is exactly zero and nothing is unattributed; the page is still rendered and served with its empty state. A manifest withoutsections— the deployed route predates the field until the next Functions deploy — drops nothing. Coder-corner and code pages are deliberately not counted: they fall back to the legacyblogscontainer, which the manifest does not read, so their zero would not mean an empty page. - ADR 0028 designs the Cosmos out-of-account export (issue #231). Weekly
full export plus daily change-feed deltas, run as a timer that fans one
queue message per container onto the existing platform jobs worker, written
gzip-NDJSON to a private
cosmos-exportcontainer on the RA-GRS content account at Cool tier with a 35-day lifecycle, monitored by an alert on the missing run. The 72 provisioned containers are classified: 43 authored and 10 configuration containers get fulls and deltas, 7 operational-record containers get fulls only, 12 regenerable, seed and transient containers are excluded, and a test will hold the classification to the provisioned list. Deletes reach the copy through the weekly full, accepted and written down. Status is Proposed: RPO 24 h / RTO 8 h, the class C policy, the same- subscription copy and the LRS host storage acceptance are the owner's four decisions, listed on #231. - Cost Analysis costs Cosmos recovery against the measured account size
(#385, issue #231). The owner measured
DataUsageon 2026-09-06: 2.39 GB. The page gains a section pricing what recovery already costs (continuous 30-day backup about $0.48 a month, a point-in-time restore $0.36) and the two export shapes from #231 per week and per month: daily full exports at about $18 a month in request units versus weekly full plus change-feed deltas at $3–4, both with 30-day blob retention on the RA-GRS content account. The numbers settle the design — weekly full plus deltas, Cool tier, about $5 a month for the whole recovery posture — and the measurement plan gains a line to re-read the size when the exporter is armed. Prices are Microsoft Learn list prices for one Central US region, with the calculator named as the place to re-verify. - One sitemap entry per URL, and listing pages that say "nothing published
yet" when that is the truth (#373, part 1). The content manifest carried
one slug three times, so the sitemap advertised the same blog URL three
times and the pre-render wrote the file three times;
buildManifestnow keeps the first occurrence and names the rest inskipped, and the pre-render dedupes its route list as a second guard.ContentListingTemplatedistinguished nothing from filtered-to-nothing only by accident: with zero items it still said "found matching your criteria" and offered a Clear Filters button. It now says "No {type}s published yet." and the published pages audit recognises that copy (and the frameworks page's) as an empty state rather than a defect. Part 2 — dropping zero-item section pages from the sitemap — stays on #373. - The audit report records the post-deploy re-run, and empty-state copy no longer marks a full page empty (#384). After #376, #379 and #380 were deployed on 2026-09-06 the crawl went from 0 works / 11 empty / 107 defect to 68 / 48 / 2; the dated report carries both columns. Three of the 48 were landings marked empty because their podcast widget says "No episodes available yet." beside two thousand characters of real content, so the crawler now counts empty-state copy only on a page under 800 characters and notes it otherwise. The rest of the 48 are real: about thirty section pages with nothing published behind them, now the stated scope of #373.
- Copilot code review MCP configuration applied (#378; issue #369, closed
2026-09-06). The owner completed every step the runbook laid out, in
order: the
hcw-azureapply that createdgithub_copilot_reviewand its seven resources;COPILOT_REVIEW_CLIENT_IDseeded from the new output; the setup workflow dispatched by hand and green on its second run (the first, fifty-eight seconds after the merge, ran before the apply and failed on an empty client id — the runbook now says steps 1 to 3 wait for the merge and what that failure looks like); the read-only GitHub App HCW Copilot Review Reader registered, installed on this repository only, its App ID and private key stored; and the five-server configuration pasted into the repository's Copilot settings. The runbook status and the required-inputs rows record the values as set. The first real review, of #378 itself, then showed two things the documentation had left open. Copilot code review does runcopilot-setup-steps.yml, but in Copilot's own runner, which resolves novars.*:azure/loginran with every input empty while the same file dispatched by hand signed in fine. The workflow now reads the client, tenant, subscription and App identifiers assecrets.COPILOT_REVIEW_… || vars.…, so the Agents store Copilot exposes is preferred and the repository variables still serve a manual run; theoidc-subjectsguard reads the variable out of that expression; the standards record the identifiers-in-Agents exception; and the owner mirrored the four values the same day (step 2b, issue #381, closed 2026-09-06). And a review session runs MCP in read-only mode, keeping only tools whose servers annotate themreadOnlyHint: true— the session log keptterraformand dropped every other server, the built-in Playwright included, as having no allowed tools. The Azure and GitHub servers annotate; the runbook records what that means for the two documentation servers and how to read the next session log. Every review session of #378 itself ran the step listmainheld at the time, so the first review after the merge is the one that shows the new steps. - Four landing pages no longer request hero images that do not exist
(#380, issue #371).
/gcp,/github,/terraformand/finopseach listed five/images/<provider>-hero/N.pngfiles that were never added, so every visit loaded with seven 404s and six broken image boxes (the audit'sbroken imagesclass). The lists are empty until the sets exist — the carousel renders nothing for an empty list — andfrontend/scripts/hero-assets-exist.test.jsnow fails the frontend suite on any/images/…path a landing page references that is not underpublic/, so a set cannot be referenced before it is added. #371 stays open for the assets themselves (1155×924 RGBA PNG, likeazure-hero/). - The public podcast list hides episodes whose media is gone (#379, closes
#372). PodBean's CDN has answered 404 for every stored episode since
2026-09-05, and
/azure/audiorendered a player for each that played nothing (the audit'smedia not servedclass).listPodcastsnow drops a row whosemediaUrlhost is onRETIRED_PODCAST_MEDIA_HOSTS(mcdn.podbean.com,feed.podbean.com) or that carries amediaUnavailableAtmark, andtotalcounts only what it serves; the rows stay in Cosmos until the new host's feed (#349) replaces them, so no data-plane write is needed. The page falls back to its own "No episodes available yet." copy, which is the honest state. A per-run liveness check that setsmediaUnavailableAtis deferred until a feed exists again: with the list empty the timer has nothing to check. TODO.mdpoints at the board (#377, closes #362). The handling rule and the "where the open items live" section name the organization project (https://github.com/orgs/HybridCloudWorks/projects/1) and its Priority field; the owner enabled auto-add on 2026-09-06, so a new issue lands there without a manual step. The index table stays as the offline copy.- Every pre-rendered page was a spinner with the real page hidden below
it; now the page is the page (#376, closes #370). The audit (#361) found
React error 419 on every hydration and two inline scripts the CSP blocks.
The cause was one React default: Fizz outlines any completed Suspense
boundary larger than
progressiveChunkSize(12.8 kB) — the shell gets the route's fallback spinner and a<template id="B:0">, the page follows as<div hidden id="S:0">, and an inline$RCscript swaps them in the browser. Every real page is bigger than 12.8 kB, so since the pre-render step shipped (#296) each document has carried a visible spinner in<main>, a hidden copy of the page, and a swap script this site's hash-less CSP refuses to run — hydration then threw 419 and re-rendered from scratch, and anything that does not run JavaScript read the fallback.prerender-entry.jsxnow passesprogressiveChunkSize: Number.MAX_SAFE_INTEGER, so Fizz inlines every completed boundary and the output has no$RB/$RCscripts at all; the CSP stays exactly as strict as it was.prerender.mjsrefuses to write the streamed form (findStreamedBoundary, tested for each of its three shapes), because the existing shell floor was blind to it: header, footer and spinner cleared 420 characters easily. Verified on a full local build: 120 documents, zero pending markers, zero runtime scripts, the route's<main>inline in each. The audit'sconsole csp-inline-scriptandconsole react-hydration-419classes — 107 of the 118 crawled pages — should clear on the next deploy. - Every published page audited, in a browser, with a verdict each (#375,
issue #361).
frontend/scripts/audit-published-pages.mjscrawls every URL in the live sitemap with Playwright Chromium and records HTTP status, failed and 4xx/5xx requests (the public API called out separately), console and page errors classified by name, provider identity (title, og:title and theme class against the URL's provider — the #183 class), empty-state copy and main-region size, broken images and unreachable media; verdict works / empty / defect per page, a matrix and a defect-class summary as JSON and Markdown. Failures the repository has already decided to live with (the #175 data snapshots) are notes, not defects, so a decided item is not re-raised every week.audit-published-pages.ymlruns it Mondays and on demand and uploads the matrix;npm run audit:pagesruns it locally. The first run — 120 sitemap entries, 118 unique URLs (one repeated three times; the crawler de-duplicates and reports the count), 0 works, 11 empty, 107 defect — is recorded atdocs/architecture/published-pages-audit-2026-09-06.md, and its defect classes became five issues: #370 (every prerendered page ships a pending Suspense boundary and the CSP blocks React's completion scripts, so hydration error 419 fires site-wide — the reason 107 pages are "defect"), #371 (twenty landing hero images missing for four providers), #372 (podcast rows with dead PodBean media still served), #373 (six empty section pages in the sitemap) and #374 (curated article bodies hotlinking upstream images). The audit closes when every row has a verdict and every defect has an issue, which is now; fixing them is the issues' work. - Copilot code review gets an MCP configuration (#368). Every Copilot
review had been ending with the hint to configure MCP servers for
context-aware reviews.
.github/copilot-mcp.jsonis now the reviewed source of record: five official, read-only servers chosen for what the repository contains. Three need no credential — Microsoft Learn (Azure Functions, Cosmos DB, Key Vault, Static Web Apps, Entra ID), Cloudflare's public documentation server (the edge availability probe and DNS) and the HashiCorp Terraform MCP server, pinned by image digest (@sha256:…, the immutable reference; the1.3.0tag beside it is for readability only, since a tag can be retargeted) and held to the eight Registry read tools on both its command line and Copilot's allowlist, with no HCP Terraform token so the live workspace stays unreachable. Two reach live systems and are kept read-only by construction. The Azure MCP Server signs in with no stored secret:copilot-setup-steps.ymlrunsazure/loginunder OIDC in thecopilotenvironment as a new user-assigned identity,github_copilot_reviewininfra/oidc.tf— Reader on the four workload resource groups and nothing else, trusting only the…:environment:copilotsubject in both forms — and the server runs--read-onlywith fourteen hand-picked control-plane tools (no Key Vault, Cosmos or blob content, no log queries), pinned on its command line and in Copilot's allowlist. It is a third identity rather than a reuse ofgithub_reader, which holds a config write. The GitHub MCP server is widened to the Actions, code-scanning, Dependabot and discussions toolsets with no personal access token of any kind: a dedicated, read-only, single-repository GitHub App (HCW Copilot Review Reader) whose one-hour installation token the same setup job mints per session throughscripts/github-app-token.mjs— now taking aGITHUB_APP_PERMISSIONSoverride so the manifest publisher's write scope and the reviewer's read scope come from one minter — and hands to the digest-pinnedghcr.io/github/github-mcp-servercontainer over--env-file, running--read-onlywith thirty-one named read tools on its command line and in Copilot's allowlist. The App's read-only permissions are the ceiling for anything its key could ever mint. Theoidc-subjectsguard learned the new client-id variable,set-github-variables.ps1seeds it, and the standards pages record the variables and the private key. The runbook Copilot code review MCP servers carries the reasoning, the tool table, the owner steps in order (apply, seed, prove the sign-in, token, paste) with what success looks like at each, and the rollback; the code-review skill names which server to consult per component. The owner steps that turn it on are issue #369. - ADR 0027 records the documentation-site decision (#367, issue #360).
The move from the Wiki to
docs/shipped in #363 with its reasoning spread across the PR, the issue and the CHANGELOG; the ADR puts it in the register where the next reader will look — source layout, MkDocs Material and why not Docusaurus or Jekyll, the root-document hook, the Pages deploy and DNS-only CNAME, the redaction gate, the Wiki retirement, and the accepted consequences (strict builds, the uncurated archive, the one reused ADR number). Closes the last acceptance item on #360 that a session can close; switching the Wiki feature off after a week is the owner's. - Retire Wiki stubs the Home page too (#364). The dry run on 2026-09-06
reported
keep (not in map): Home: Home maps to the site root, whose path is the empty string, and the guard tested truthiness instead of presence. It now testsis None, so 141 pages are stubbed rather than 140 and the Wiki's landing page points at https://docs.hybridcloudworks.com/ like the rest. Found by running the workflow withdry_runon, which is what the input is for. - Documentation moved from the GitHub Wiki to a MkDocs site under
docs/, published to docs.hybridcloudworks.com (#363, issue #360). Owner decisions 2026-09-05: MkDocs Material, host namedocs.hybridcloudworks.com, source is the formerwiki/folder plus the live Wiki. The live Wiki held 106 pages that were never staged in the repository (ADRs 0001–0017, the ADR template, the target-architecture and Well-Architected pages, the migration inventory and smoke-test records, and about eighty Firebase-era guides), so it was cloned and everything came across: 141 pages, arranged asdecisions/,runbooks/,standards/,architecture/,content/,history/(the Azure migration record, banner-marked) andarchive/(the Firebase era, banner-marked, indexed from its own page). Wiki-style links were rewritten to relative paths andmkdocs build --strictfails the newDocs sitecheck on any broken link or anchor, which is what found the seven stale anchors that the Wiki had been serving as dead links. The duplicate ADR number 0021 (Container Apps CI runner, deferred, versus Key Vault purge protection) is kept as two records with a note on the first.README.md,CHANGELOG.mdandTODO.mdstay at the root and are pulled into the site at build time byscripts/docs/hooks.py, so nothing is copied by hand. Deployment isdocs-pages.ymlwith OIDC to thegithub-pagesenvironment; the Pages site was created with the Actions build type on 2026-09-05.sync-wiki.ymlis replaced byretire-wiki.yml, aworkflow_dispatchthat overwrites every Wiki page with a pointer to its new URL (dry-run by default) — to be run once the domain serves, since a pointer to a host that does not resolve is worse than the old page. The repository-structure policy now sanctionsdocs/instead ofwiki/, allowsmkdocs.ymlat the root, and requires the README to name the docs site. Terraform addscloudflare_dns_record.docs_pages, a DNS-only CNAME tohybridcloudworks.github.io(proxying it would leave GitHub unable to issue the certificate); the apply and the Pages custom-domain setting are the owner's, in that order. Every current-state reference to the Wiki in the repository was repointed (README, CONTRIBUTING,infra/README.md, the issue-template contact link, the code-review skill, the infrastructure plan's evidence URLs); the historicalwiki/links in this file were rewritten to theirdocs/paths so they resolve again on GitHub. - Open work moved from
TODO.mdto GitHub issues (#359). Owner instruction 2026-09-05: the items are worked from the issues list from now on, so the tracker's remaining sections — the five optional seeds, the Labs test-coverage case and the four live confirmations — became issues #350–#358 andTODO.mdkeeps only the accepted risks, an index to every open issue, and a handling rule saying an open item is an issue. Three labels were added so the list reads without opening anything:owner-gated(a credential, console action or spend decision),live-check(needs an authorized operator against the deployed estate) andpodcast. The three pre-existing issues were re-verified againstmainthe same day and each carries a dated comment: #231 (no Cosmos exporter, no timed restore), #180 (createContentFromRecordingstill underrpc.notImplemented, though #342 removed its credential blocker) and #127 (no managed WAF ruleset in Terraform). Found while doing it, and filed rather than fixed here: the Podbean feedfetchPodcastFeedshas polled every two hours since #332 returns HTTP 410 Gone, so the timer has failed on every firing and the five podcast pages still advertise a PodBean subscribe button that leads to it (#348); and the replacement stack the owner asked for — RSS.com hosting, ElevenLabs speech as a thirdspeech/index.jsprovider, StreamYard for live and recorded shows — is evaluated with vendor facts, prices, an integration design and the owner decisions it needs in #349. Thecheck-todo-changelog-movementgate passes: noT-identifier left the tracker in this change. - The tracker is open work only, and the repository is leaner (#346).
Owner instruction 2026-09-06:
TODO.mdkeeps four things — the optional seeds, the one live test-coverage case, the live confirmations still to come due, and the accepted risks — and nothing else. Everything below moved out of it verbatim on that day (headings demoted so the changelog's own structure holds): the status narrative, the attack sequence, the "what is open" note, the watch-out-for note, the long-formT-518record, the owner-gated preamble carried fromREVIEW.md, the owner-decisions table, and the struck live confirmations and accepted risks. Removed from the tree in the same change, each with no remaining consumer:frontend/.firebaserc(the Firebase project pointer; the site has been on Azure since 2026-08-21),scripts/cutover/02-swa-token.ps1(minted a deploy token that T-727 deleted on 2026-08-30),frontend/scripts/axe-quick-check.mjs(an ad-hoc accessibility probe wired to nothing),frontend/.lighthouserc.jsonandfrontend/.pre-commit-config.yaml(neither referenced by any script, hook or workflow), and.azure/insights.json(an empty array). #346 also removedfrontend/.copilot/and.github/templates/; that was wrong, and #347 restored both the same day at the owner's direction — Copilot is GitHub's tooling and readsfrontend/.copilot/from the frontend project, and the 21 document templates are kept to be used as needed. The repository-structure validator's allowlist lines for both directories are back with a comment recording why.
Status — 2026-09-05
Nothing is open as of 2026-09-05. The last two items —
T-518, arm the remaining timers, andT-726, prove the nightly refresh — closed on the owner's decision that evening, with all 18 timers armed and the observation reads no longer a gate. Both records are in CHANGELOG.md. What follows below the status is kept as the working method and the long-form evidence, not as a list of work.The one live degradation closed overnight.
T-763— the manifest route merged on 2026-08-30 against a Function App last deployed 84 minutes earlier — was fixed by Deploy Functions run 81 at 2026-08-31 23:45 UTC. Verified rather than assumed: manifest run 11 at 2026-09-01 00:06 went green end to end and reported "No change to the published set", so the committed manifest already matched what the API serves. Recorded in CHANGELOG.md and removed from this list.The detection gap is closed —
T-519was armed on 2026-09-01. The probe's secret held the Instrumentation Key rather than the connection string; the piped command inedge/availability-probe/wrangler.tomlreplaced it, twelvesuccess == 1/ HTTP 200 rows landed on the*/5cadence before arming (a full PT30M window needs six), and the apply createdalert-api-reachability-prod-cusinrg-web-site-prod-cus. The registered function count was 122 before and after the apply's restart. Recorded in CHANGELOG.md and removed from this list.
T-719andT-721closed on 2026-09-02, by owner decision. The host verbosity is cut at the source (#321,host.jsondefaultandFunctionto Warning), the deploy was dispatched the same day, and the owner closed both without gating on the cap-day reading — the below-cap volume is expected confirmation, not a closure criterion. The accepted risk (log alerts sleep if the cap ever binds again) is recorded below; the Basic-table-plan move stays in reserve in CHANGELOG.md if the reading ever says the cut was not enough. What remains is one observation and one procedure.The architecture review is closed. All 62 findings (
T-701…T-762) are resolved or carried below as owner gates. The per-finding record — method, evidence standard, every failure mode and outcome — is wiki/Architecture-Review-2026-08.md, which is now a dated historical document rather than a live list. Its last three owner-gated findings are all closed —T-749on 2026-08-31,T-719andT-721on 2026-09-02.The
hcw-azureworkspace is VCS-connected, and auto-apply is off. Merging infra code queues a plan that waits for approval at https://app.terraform.io/app/hcw/workspaces/hcw-azure/runs — it does not apply on its own, andterraform applyfrom a desktop is refused outright. Every run carries the permanent diff, which replaces threeazapiresources and restarts the function app.This file was wrong about that twice in opposite directions, and the reason is worth keeping: HCP Terraform's workspace Description is free text beside the real settings, validated by nothing, and it read "CLI-driven; no VCS connection". That sentence was read and reported as configuration. Corrected in the workspace on 2026-08-31. A field that contradicts its own workspace reads exactly like a setting.
This table is the list below, and nothing else. An earlier version counted
five, because the two owner actions left by closed findings were tracked in a
different section from the five findings that carry T- numbers — so the
summary said five above a list of seven. That is the T-722 defect, in the
document restructured to prevent it, and it is why there is now one table
rather than a count and a list that can drift apart. Found by review, 2026-08-31.
| # | Open item | Priority | What closes it |
|---|---|---|---|
| — | (empty since 2026-09-05) | — | — |
The table emptied on 2026-09-05. T-726 (the nightly refresh) and T-518
(arm the remaining timers) were the last two rows; both closed by owner
decision that evening and their records, with the wave table that T-518
carried, are in CHANGELOG.md. The 2026-09-02 audit that
traced every container the public read layer reads back to whatever writes
it found T-764 (closed 2026-09-04 by Wave 2) and T-765 (closed 2026-09-05:
the owner retired the insights panel). T-766, the witness gap that Wave 2
exposed, closed 2026-09-05. All records are in the changelog.
When the table fills again, the table and the sections below it are kept in
the same order, and that order is the one to work them in — not a sort of the
Priority column. Checked against each other by number AND by T- identity,
never by counting rows: on 2026-09-01 an edit reordered the table while
renumbering the sections in document order, and because both still read
1..5 a digits-only check passed with every row pointing at the wrong section.
Closed on 2026-08-31 and removed from this list: seeding TFC_TOKEN (done),
and T-749, the SCM lock — Terraform owns
scm_ip_restriction_default_action, a live read shows Deny all, so the
variable is already true and the last Deploy Functions run succeeded on
2026-08-30 at 01:21 UTC. The tracker had it open against a reality where it was
applied.
The attack sequence — one working order across everything open
This section sequences what the rest of this file already tracks; it adds no items and restates no procedures. Each phase points at the one section that carries the commands and the success criteria, so this list cannot drift from those sections the way a restatement would — the T-722 lesson, applied in advance. The ordering rule is dependency, not priority: the timers arm after the verbosity cut has deployed, so their volume lands in real headroom.
| Phase | What | Where the procedure lives | Why this position |
|---|---|---|---|
| 1 | ~~Fix the probe's secret, wait for six availabilityResults rows, arm the reachability alert (T-519)~~ |
Done 2026-09-01 — record in CHANGELOG.md | Twelve healthy rows, alert-api-reachability-prod-cus live in rg-web-site-prod-cus, function count 122 before and after the restart |
| 2 | ~~Settings sweep: delete the three stale workspace variables, set the production deployment-branch rule, decide the two ruleset booleans~~ |
Done 2026-09-02 — record in CHANGELOG.md | Three variable rows deleted; production restricted to main; ruleset decided: branches must be up to date before merge, thread resolution not required |
| 3 | ~~Cut the host verbosity at the source (T-719), pull T-721's lever~~ |
Done 2026-09-02 — record in CHANGELOG.md | Closed by owner decision with #321 merged and the deploy dispatched; the below-cap cap-day reading is expected confirmation, not a gate. The SWA tier question moved to the Owner decisions and external access table (Owner decisions, below in this file) |
| 4 | ~~Arm the remaining timers in three applies — 3a, 3b, then waves 4, 5 and 6 together (T-518)~~ |
Done 2026-09-05 — record in CHANGELOG.md | All 18 timers armed; the two reapers armed one per apply with the delete switch flipped after two zero dry-runs; the observation reads dropped as a gate by owner decision |
| 5 | ~~Prove the nightly refresh's App-token path (T-726)~~ |
Done 2026-09-05 — record in CHANGELOG.md | Closed by owner decision on the built-and-configured state; the first content change remains the live proof, and the failure signatures (401 = wrong App id, 404 = App not installed) are in the changelog entry |
| 6 | Optional features: seed the keys and documents you actually want (the insights-panel decision, T-765, was made 2026-09-05: retired) |
the owner-gated issues | Decisions, not repairs — nothing above depends on any of them |
| 7 | Live confirmations as they come due: Entra token claims, the timed restore against RTO 8 h / RPO 24 h (issue #231), third-party webhooks, the authenticated Labs check | the live-check issues and issue #356 | Each needs a live environment or a third party on its own schedule; none blocks Phases 1–5 |
The deliberately unscheduled feature backlog — analytics-informed topic
weighting, the voice-drift monitor, stale-post refresh, A/B titles, the
duplicate-angle advisor — stays in
Blog-Machine § Backlog on purpose: none of it is open
work, and pulling it here would turn this file into a wish list. The one
backlog entry that is also a real route gap, createContentFromRecording,
already lives in the AI-providers and third-party rows of
the Owner decisions and external access table (Owner decisions, below in this file) — it stays in
.azure/api-surface.json notImplemented until its provider credentials
exist.
What is open, and exactly what closes it
Nothing, as of 2026-09-05. The sections that stood here — T-726 and T-518
— moved to CHANGELOG.md with their evidence, including the
wave table T-518 carried. The long-form records below stay as the account
of how each was established.
Watch out for
-
Deployment drift is now measured, not noticed.
.github/workflows/monitor-deploy-drift.ymlruns every four hours and fails when a service has been behindmainfor 24 hours or more, emailing the owner the way the other monitors do. A failure names the service, the number of undeployed commits touching its paths, and the subject of the oldest one.The fix is to dispatch that service's deploy. Both are dispatch-only by a recorded decision, so merging never ships them; this check is the thing that says so. Deploying clears it on the next run.
It measures AGE, not commit count, and that is the whole design: the manifest 404 was one commit behind and the frontend was thirty-five. No count threshold separates those. Both had sat undeployed for days.
The long-form records
Everything below is evidence for the items above, not a second list of them. Each carries how the finding was established, what was observed, and the limits of that observation — the part a summary loses and the next reader needs before changing anything.
T-518 — 15 of 18 timers are still no-ops; the mechanism is proven
Closed 2026-09-05: all 18 armed. Kept as the evidence record; the wave table and the closing decision are in CHANGELOG.md.
Gate: owner — TODO.md, Timers and the availability test.
functions/src/functions/schedulers.js checks FEATURE_FLAG_SCHEDULERS first
and skips the handler before reading the per-timer flag. Until 2026-08-24 that
setting was a hardcoded literal in main.tf, which meant enabled_timers could
not arm anything at all and no document said so; it is now
var.schedulers_master_enabled. Arming needs both it and a name in
enabled_timers, through the four gates in
Cutover-Runbook step 5 — where the acceptance
criterion is the observed invocation, not the applied setting.
The master switch went true on 2026-08-30, with CHECK_AGENT_HEALTH and
CLEANUP_TEMP_STORAGE in enabled_timers. That left sixteen no-ops at that
moment — see the paragraph below for where the count stands now — so the
platform still ran almost no scheduled work, but "nothing is scheduled" was no
longer true and the arming mechanism was no longer an assumption.
PUBLISH_SCHEDULED_CONTENT was armed and then observed on the evening of
2026-08-30 Chicago time — 2026-08-31 in UTC, which is why the surrounding
entries and the timestamps below appear to disagree. Every timestamp in this
section is CDT (-05:00), matching what the host itself writes; the two UTC
instants are named as UTC where they appear. The observation is unusually
clean, because it straddles the apply:
publishScheduledContent 8 invocations 4 ran 4 skipped
first 2026-08-30 18:30:00 CDT last 2026-08-30 20:15:00 CDT
Eight invocations at exactly fifteen-minute spacing with no gap. The four at 18:30, 18:45, 19:00 and 19:15 skipped; the four at 19:30, 19:45, 20:00 and 20:15 ran. The apply that set the flag landed at 00:30 UTC on 2026-08-31, which is 19:30 CDT on 2026-08-30 — so the split falls on the boundary rather than near it. That is the same timer, observed skipping and then running, with the flag change as the only variable: a stronger reading than a bare "it fired", because it also proves the gate the flag controls.
The clock came from the host in the same run: eight ScheduleStatus rows, all
-05:00, firing on :00 :15 :30 :45 Chicago local. So both halves hold for
this timer independently.
One honest limit, which the script prints itself: a RAN invocation is one whose
traces carry no skip line, and a dropped .User trace would look identical.
The durable side effect — content actually transitioning to published — is the
second witness Cutover-Runbook Gate 4 asks for, and it only appears when
something was genuinely due. Nothing was, so the no-op is correct and
unwitnessed by that second path.
So of the eighteen: fifteen remain no-ops, and CHECK_AGENT_HEALTH,
CLEANUP_TEMP_STORAGE and PUBLISH_SCHEDULED_CONTENT are armed and observed.
It gates two other things, which is why it outranks its own blast radius:
the Blog Machine's scheduled throughput (forgeScheduled,
publishScheduledContent), and the Cosmos backup-tier change from T-707,
which only pays for itself once scheduled work is generating documents. It also
gates any meaningful cost measurement — a bill taken while nothing is scheduled
prices an idle platform (Migration-Plan §7).
Both halves of the gate passed on 2026-08-30. The clock half needed nothing
armed: app.timer() registers on the real schedule unconditionally and the flag
is checked inside the handler, so every timer had been firing since deploy and
logging disabled — skipping, with the host writing ScheduleStatus carrying
WEBSITE_TIME_ZONE offsets each time. cleanupTempStorage reported
"Last":"2026-08-29T00:00:00.005764-05:00", "Next":"2026-08-30T00:00:00-05:00"
— local midnight, offset applied, which is the §7 comparison delivered by the
platform rather than computed by a script.
The handler half came from checkAgentHealth after arming: twelve invocations
between 04:55:00Z and 05:50:00Z, exactly five minutes apart with no gaps, no
disabled — skipping anywhere in the window, and the handler's own
[checkAgentHealth] 0 agent(s) marked offline on each one. Host row and .User
row are separate emitters, which is what "two independent witnesses" means; the
zero is a correct no-op, not a missing witness.
Two departures from the runbook are worth recording. Both timers were armed in a
single apply rather than one at a time — safe here only because
TEMP_STORAGE_CLEANUP_DELETE pins cleanupTempStorage to dry-run
(functionapp.tf), so the second timer could not touch data. And the evidence
above was read with direct KQL, not through
scripts/cutover/05-verify-timer.ps1: that script reported a tally of tens of
thousands of invocations for a query returning two rows, and was rewritten the
same day to aggregate in the workspace instead.
That rewrite treated a symptom, and the miscount was root-caused on
2026-08-31 instead. az on Windows is a batch file, which cannot receive an
argument containing a newline, so a multi-line query handed to
--analytics-query arrived truncated at the first line break: AppTraces
survived and the whole pipeline after it was discarded. The call still exited
0. Azure ran the truncated query and returned every unfiltered row in the
table, so the script rendered tens of thousands of rows with every projected
column blank — and its preflight, truncated the same way, read a missing
column as zero and reported "no worker traces" in the same run. The tell was
in the record for a week: the count barely moved between -Hours 1 and
-Hours 24, and a window that changes 24-fold cannot return the same total
unless the window is not being applied. Fixed by flattening every query before
it leaves PowerShell, and by asserting that returned rows carry the columns the
caller asked for — a truncated query answers a different question rather than
failing, which is why nothing in the error handling ever fired.
Confirmed end-to-end in the same run (2026-08-30 CDT / 2026-08-31 UTC), on
the Windows host where it failed:
the same command that had returned 58,265 blank rows returned one correct
summary row, the ScheduleStatus section came back filtered to the requested
timer and ordered, and the preflight reported 431 worker traces where it had
been reporting none. That last number is the one worth remembering — the
"telemetry gap" this script reported three times never existed.
The fifteen that remain go one at a time, each observed firing before the next is added.
Owner-gated work, carried from REVIEW.md
Everything from here to the end arrived from REVIEW.md on 2026-08-29. These
need tenant administration, production approval, a credential, external access,
or a live confirmation — none of them can be closed from a checkout.
One section was deleted rather than carried: "Immediate: restore admin
access". It described a live 403 from POST /api/bootstrapCurrentUserAdmin
and asked for the Entra Admin app role to be assigned. The owner confirmed on
2026-08-29 that the admin portal loads and signs in immediately, so the role is
assigned and the 403 is gone. It had been sitting at the top of the document
marked Immediate — the loudest item in the tracker, describing something that
was already fixed. The Admin app role assignment survives as one clause of the
Entra row below, which is where it belongs.
Owner decisions and external access
| Item | Human action required | Safe repository-side state |
|---|---|---|
| Entra application | Confirm SPA client ID, tenant ID, API audience/scope, redirect URIs, consent, and the Admin app role assignment |
frontend/.env.example documents names; no client secret is committed |
| Frontend release | Approve whether releases remain manual or become push-triggered. The credential half of this row is closed (T-727, 2026-08-31): the deploy mints its token from ARM per run under federated identity, and the stored secret is deleted — there is nothing left to provide or rotate | deploy-azure-frontend.yml stays dispatch-only |
| Production infrastructure | Approve HCP Terraform plan/apply and any DNS, custom-domain, or Cloudflare changes | Terraform remains the infrastructure source of truth |
| ~~Timers and the availability test~~ | Done 2026-09-05 — record in CHANGELOG.md | All 18 timers armed by 2026-09-05 (T-518); the reachability alert armed 2026-09-01 (T-519) |
| Recovery objectives (decided 2026-08-30) | RTO 8 hours, RPO 24 hours. Chosen to match what the estate can actually meet today — periodic Cosmos backup, one operator, no on-call — rather than an aspiration nobody has rehearsed. Deliberately not RPO 1 h: that needs the T-707 continuous-backup tier, which costs money while the platform is still nearly idle and would commit to a drill that has never been run. Revisit once scheduled work is generating documents, which is also when T-707 starts to pay for itself. The remaining work in issue #231 is now measurement against these numbers — a timed restore — not the numbers themselves | Cosmos carries Continuous30Days; content/media storage is RA-GRS with versioning and soft delete; Functions host storage remains LRS with soft delete. No scheduled out-of-account Cosmos export exists, no restore has been timed, and no result is justified against a stated objective |
| Key Vault | Provide only the secrets needed by enabled features; never put values in GitHub variables or Vite config. The approved procedure changed on 2026-08-29: seeding is now Admin → Platform → API Keys, and the desktop script is break-glass rather than the default path | Code reads secrets server-side and degrades optional integrations when absent |
| Function App vault write (decided 2026-08-29) | Approved. The app may create new secret versions, through a CUSTOM role holding only Microsoft.KeyVault/vaults/secrets/setSecret/action — not Key Vault Secrets Officer, which would also grant get, list, delete and purge. It may also refresh its own Key Vault references (Microsoft.Web/sites/config/Write, scoped to the one site, with config/list/action excluded so it cannot read its settings back). Weighed against what it replaces: the previous procedure opened the production vault's firewall to a human IP on every rotation, and left it open once |
The app cannot read a secret back out of the vault, cannot delete one, and cannot enumerate its own app settings through ARM. /api/cms/secrets is super_admin on both verbs and returns no value in any response — asserted by scanning the whole serialised body, not by trusting a field list |
| GCP pricing integration | Seed GCP-BILLING-API-KEY if the GCP column in the public pricing tool is wanted, or leave it unseeded and that column stays absent. Get it from the GCP console: enable the Cloud Billing API, create an API key, restrict it to that API. This is not a billing credential — the Cloud Billing Catalog API serves the public price list, and it is read for the site's comparison tools, not for anything this estate is charged for |
No GCP credential is stored in the repository. The service-account JSON this row used to ask for is retired (2026-08-29): the API key is what Google documents for this API, and it removed a vault SDK client, an OAuth library and a bespoke seeding script |
| AI providers | Decide which external providers should be enabled and provide their keys through Key Vault | The AI router only enables a provider when its server-side key is present |
| Third-party integrations | Provide owner-controlled Publer, Klaviyo, YouTube, Telegram, Hostinger, or other credentials and approve webhook changes before activation | Integration secrets are server-side and optional paths remain gated |
| Listen & Learn speech | Nothing to provide: it synthesises with Gemini TTS on the existing GEMINI-API-KEY. Audio is billed against that key at roughly $0.17 an episode / $0.87 a certification on the default model; every run is logged to the AI Engine usage tab under "Breakdown by Feature", so the spend is checkable there rather than estimated. Azure AI Speech is a written, tested fallback for the day the preview Gemini TTS models are retired; using it means creating a Cognitive Services resource, which is a spend decision and is not assumed |
Provider is chosen by key presence, Gemini first. With no key at all the feature still publishes each episode's transcript, takeaways and videos and records audioError instead of failing |
| Listen & Learn video links | Seed YOUTUBE-API-KEY if the curated "watch next" links are wanted. One certification costs ~505 of the default 10,000 daily quota units |
Optional. Without it, episodes generate and publish with an empty video list |
| VPS Labs agent | Provide the host operator, Entra client/certificate, API scope, and deployment approval for the Hostinger agent | vps-agent/ uses the API and holds no database credential |
| ~~Static Web App tier~~ | Done 2026-09-05 — record in CHANGELOG.md | Owner decided Free (#341). Both Free limits verified from the repository before the change: exactly two custom domains (apex and www, Free's maximum) and a build of about 117 MB against 250 MB. Two-way door: a third domain or a bandwidth overage is the signal to move back |
~~ai_insights container~~ |
Done 2026-09-05 — record in CHANGELOG.md | Dropped by #340's apply, which the owner read and approved at exactly one destroy plus the azapi baseline; #341 restored the container prevent_destroy guard in the apply that followed |
| ~~Provider-section go-live~~ | Done 2026-09-02 — record in CHANGELOG.md | Owner decided all 24 pages ship at once. Every provider page is live; GUARDED_FILES in frontend/scripts/validate-provider-pages.js is now empty and the guard mechanism is retained for the next page that needs it |
Live confirmations completed
response in the deployed environment.
change.
- ~~Observe an alert actually being delivered.~~ Done 2026-08-30. A
sample budget alert from
ag-plat-prod-cus-01arrived in theops-emailreceiver's inbox (fired 21:36 UTC; receiver statusEnabled). The email path is proven end to end, across the subscription boundary, for the first time. Use the CLI, not the portal button. The portal's Test action group reported "There was a problem completing this test" with status Unknown on two attempts and delivered nothing. The same operation through the CLI returned"Status": "Succeeded"/"state": "Complete"and the email arrived a minute later: az monitor action-group test-notifications create \ --action-group ag-plat-prod-cus-01 \ --resource-group rg-mgmt-plat-prod-cus \ --subscription 00000000-0000-0000-0000-000000000003 \ --alert-type budget -a email ops-email usecommonalertschema The API response and the inbox are two independent witnesses, which is the standard the Cutover-Runbook asks for and what the portal alone could never supply — itsUnknownwas a verdict on nothing. Note also the argument shape: it is-a email <name> <address> <schema>, not a--email-receiverflag, and the whole thing is one line — a backtick continuation pasted into Git Bash gets read as a command name. Second, this file named the groupag-ops-prod-cusuntil the same day, which is no resource:observability.tf:36buildsag-plat-${environment}-${region_abbreviation}-${instance},hcw-opsis only the short name, and it lives inrg-mgmt-plat-prod-cusin the Management subscription because the action group follows its resource group there. The SMS channel was proven the same evening and T-709 is closed. Same command shape,-a sms <name> <countryCode> <phoneNumber>in place of-a email …; read the values off the action group rather than retyping them. Both channels have now been observed delivering, which is the first time this estate has had a second path to fall back on. approved a real external mutation test. - ~~Apply the Terraform change that creates the
listenandlearnblob container.~~ Applied 2026-08-30.az storage container-rm showreportslistenandlearnwithpublicAccess: None— private, as intended;PUBLIC_MEDIA_CONTAINERSinblob-paths.jsis what makes an episode reachable, not the container ACL. The same apply declares the fallbackAZURE_SPEECH_*settings, which stay unresolved and inert.
Accepted risks that closed
| Risk | Accepted | Reasoning |
|---|---|---|
~~The Static Web Apps deployment token is a Terraform output (swa_token)~~ Raised as T-722, 2026-08-28 |
CLOSED (T-727). The output was deleted on 2026-08-30 and the owner deleted the AZURE_STATIC_WEB_APPS_API_TOKEN secret on 2026-08-31; the deploy mints the token from ARM under federated identity, per run. No stored, non-expiring credential remains in this repository's secrets. Not an accepted risk any more, and kept in this table only so the row does not appear to have been quietly dropped |
The token is in state via azurerm_static_web_app.hcw.api_key whether or not the output exists, so deleting the output would hide it rather than retire it. sensitive keeps it out of logs and plan output; it is still visible on the HCP Terraform Outputs tab to anyone with state read. It is the estate's last long-lived credential — everything else a workflow uses is federated OIDC. Compensating control, 2026-08-28: deploy-azure-frontend.yml now isolates it in a job that installs nothing, so a compromised build dependency cannot reach it (T-727). Retiring it means moving the SWA deploy to OIDC, or at minimum making this an environment secret on a protected production; both need owner access. The outputs.tf header now names the exception instead of contradicting it |
T-518closed: all 18 timers are armed (#345). Owner decisions of 2026-09-05, in order: the content reaper's delete switch flipped after two dry-run firings read[cleanupSoftDeletedContent] dry-run: would delete content=0 (user=0, policy=0) refused=0 examined=0(#343); Wave 3b armed the same evening, one apply on its own; waves 4, 5 and 6 armed together in the last apply, with Publer and both Plaud tokens seeded beforehand so the wave 5 timers do real work. The owner then decided that the per-wave observation read is no longer a gate: the arming apply closes a wave, and the reads stay available from05-verify-timer.ps1and the public-witness script for anyone who wants them. Wave 3a's planted test page, soft-deleted on 2026-09-05, will be reaped a week later and is the standing live check of the origin classification. The wave table, as it stood at close:
| Wave | Timers | Record |
|---|---|---|
| 1 | PLATFORM_JOB_SWEEPER, MONITOR_PUBLISHING_PIPELINE |
Done 2026-09-02 (#323): all four gates observed |
| 2 | SYNC_RSS_FEEDS, FETCH_PODCAST_FEEDS |
Done 2026-09-04 (#332): both witnessed through the public side effect |
| 3a | CLEANUP_SOFT_DELETED_CONTENT |
Destructive, solo. Armed 2026-09-05; delete switch flipped in #343 after two dry-run firings reported zero across the board; a mark with no recorded origin is refused in both modes (#334) |
| 3b | CLEANUP_REJECTED_CONTENT |
Destructive, solo. Armed 2026-09-05 evening, one apply on its own; marks aged rejections soft-deleted, reversibly, and only 3a turns the mark into a deletion |
| 4 | FORGE_SCHEDULED, GENERATE_REVIEWER_DIGEST, FETCH_BLOG_LISTINGS |
Each spends money or sends mail; all three approved 2026-09-05 and armed with waves 5 and 6 in the last apply |
| 5 | REFRESH_PLAUD_TOKEN, SYNC_SOCIAL_CALENDAR |
Both need owner-held credentials; Publer's two Key Vault secrets and both Plaud tokens were seeded on 2026-09-05 (#342) before the apply |
| 6 | CHECK_LIVE_LINKS, REVERIFY_CERTIFICATIONS, SCRAPE_SKILLS_HUB_RSS, CLEANUP_UNUSED_CERT_IMAGES |
Three weekly timers on Monday, Sunday and Friday plus one daily timer still dry-run pinned by CERT_IMAGE_CLEANUP_DELETE; armed in the last apply |
Waves 1 and 2 were observed through all four gates (#323, #332). The
per-category host.json overrides that served as witnesses for 3a through
6 came out in this PR, returning host.json to the #321 state:
Function at Warning, Host.Results at Information.
-
T-726closed: the nightly refresh's App-token path is accepted as built (#345). Owner decision 2026-09-05. The App is installed, the workflow mints its token from it, and the ruleset admits the App; the first content change remains the live proof, and its two failure signatures —401for a wrong App id,404for an App not installed on the repository — are recorded here so the day it fails is diagnosable without the tracker. -
host.jsonraises the nine remaining timer categories to Information ahead of the last arming apply (#344, T-518 waves 4, 5 and 6). Each offorgeScheduled,generateReviewerDigest,fetchBlogListings,refreshPlaudToken,syncSocialCalendarScheduled,checkLiveLinks,reVerifyCertifications,scrapeSkillsHubRssandcleanupUnusedCertImagesgets the per-category override that was decided as its witness on 2026-09-05, so05-verify-timer.ps1reads real invocations the moment the owner adds the nine names toenabled_timers. Until that apply the only cost is adisabled — skippingrow per tick, which for the five-minute calendar sync is a few hundred small rows a day against a workspace running well under its cap. Every override comes out again in the PR that closes its wave. -
Wave 3a's delete switch is on:
CONTENT_HARD_DELETEistrue(#343, T-518). Owner decision 2026-09-05 after reading two dry-run firings (01:00Z and 05:00Z, both on their Chicago even-hour ticks) at[cleanupSoftDeletedContent] dry-run: would delete content=0 (user=0, policy=0) refused=0 examined=0: nothing has been soft-deleted for longer than the reaper's 7-day grace window, so the first armed run deletes nothing. The origin refusal from #334 holds in both modes. The live witness for the classification is a test page the owner soft-deleted the same day, which the reaper should delete a week later asuser=1 refused=0with anadmin_audit_logsrecord; the wave closes on that read, not on the flip.host.jsonalso gains theFunction.cleanupRejectedContentoverride so Wave 3b has its witness the moment it is armed. -
The Static Web App runs on the Free tier (#341). Owner decision 2026-09-05, closing the question carried in TODO.md since T-721 closed on 2026-09-02. Everything the estate uses is in Free — custom domains with managed SSL, global distribution, SPA routing, preview environments, 100 GB of bandwidth — and the two Free limits were verified from the repository before the change: exactly two custom domains (the apex and www, Free's maximum) and a build of about 117 MB against the 250 MB app limit. What Standard bought and nothing here used: the 99.95% SLA and bandwidth overage; past 100 GB a Free site stops serving instead of billing, which Cloudflare caching in front makes unlikely. About USD 9 a month, the workload's one fixed line. Microsoft documents the move in either direction, so a third domain or an overage is the signal to move back.
-
The container
prevent_destroyguard is back on (#341). #340 lifted it for one apply to dropai_insights; the owner read that plan at exactly one destroy plus the workspace's permanent azapi baseline and approved it on 2026-09-05. The guard comment now records the procedure for the next deliberate drop: lift in its own PR, read the plan for one destroy, restore in the next. -
T-766closed: every remaining timer wave has a stated witness (#338). Found 2026-09-03 when Wave 2's gates returned nothing: #321 had cut theFunctionlog category to Warning, which removed theExecutedandScheduleStatusrows the timer-observation gate read, while its own record said it had protected the table the gate used. The owner kept the cut and made the side effect the witness (scripts/verify-timer-witness.mjs, #328), which observed Wave 2; the per-categoryhost.jsonoverride was decided for Wave 3a on 2026-09-04 (#334). On 2026-09-05 the owner decided the remaining waves in one sitting — Wave 4's three timers all approved, Wave 5's two credentials both held, and waves 4, 5 and 6 to arm together in one apply after 3b, since none of the nine deletes anything — and each row in the T-518 table now names its witness: the override, added for a wave when it arms and removed when it closes. The item's closing condition was a stated witness per wave, so it is met; the per-wave work stays in the wave rows. Two records were corrected on the way: neither Wave 5 timer loops on a missing credential (both skip), and the delta-import ordering rule onSYNC_SOCIAL_CALENDARis moot since the import was retired on 2026-08-24. -
host.jsonraises one log category,Function.cleanupSoftDeletedContent, to Information for Wave 3a (#334). The T-766 decision for the first wave with no public witness. The reaper's side effect is an absence, so no container read can tell an idle run from a timer that never fired; the override restores that one timer'sExecutedrows at a few lines per invocation, which does not reopen T-719. It is a wave-scoped change and comes out when the wave closes.05-verify-timer.ps1now reads a per-category override before theFunctiondefault, so it works for this timer again. -
T-719 and T-721 closed by owner decision, 2026-09-02. The verbosity cut merged (#321) and Deploy Functions was dispatched the same day; the owner closed both without gating on the first clean cap-day reading — the below-cap volume is expected confirmation, not a closure criterion, and the evidence standard is the owner's to set on a single-operator estate. Held in reserve if a later reading says the cut was not enough: move
AppTraces(~38% of the old cap volume) to the Basic table plan at roughly USD 0.65/GB versus USD 2.76/GB. The accepted risk (log alerts sleep from cap-hit to the 08:00 UTC reset if the cap ever binds again) stays recorded in TODO.md's Accepted risks; the Static Web App tier re-justification that lived inside T-721 moved to TODO.md's Owner decisions table rather than closing silently with it. The tracker drops to two open items: T-726 (waits on the first content change) and T-518 (arm the timers). -
The Functions host stops narrating itself:
host.jsondropsdefaultandFunctionto Warning (T-719 decided, T-721's lever pulled). Twelve days of data showed daily ingestion pinned at the 0.25 GB cap — the ceiling, not demand — with the log-based 5xx/latency alerts going dark from cap-hit to the 08:00 UTC reset on most days. The owner's decision, 2026-09-02: this is a personal content site whose volume is host verbosity, not traffic, so the source is cut instead of the cap being raised for a measurement day.Host.Resultsstays at Information deliberately — it feedsAppRequests, which those alerts and the timer-observation gate read, and the pinning test refuses the change that would empty it;Azure.Core/Azure.Identitywere already pinned quiet after T-514. The cap stays at 0.25 GB as headroom. Accepted and recorded with the decision: if the cap ever binds again, log alerts sleep until the reset — the T-519 probe covers unreachability on a pipeline the cap cannot touch, andlogs_daily_capwarns at 80% first. The closure criterion stated here when this shipped — one cap-day measurably below 0.25 — was superseded the same day by the owner's decision recorded above; the below-cap reading is confirmation, not a gate. The Basic-table-plan move stays in reserve if that reading says the cut was not enough. -
The settings sweep is done — the tracker's Phase 2, three settings that were choices rather than defaults, all clicked by the owner 2026-09-02. The three stale Terraform Cloud variables (
migration_writer_enabled,cosmos_scratch_enabled,storage_scratch_enabled) are deleted from thehcw-azureworkspace — deletion, not declaration, per the 2026-08-24 removal record; the success criterion (a plan printing no "Value for undeclared variable" warnings) is observable on the next queued run. Theproductionenvironment now carries themain-only deployment-branch rule, closing the path where the environment-scoped federated credential matched from any branch (T-705's other half; required reviewers stay deliberately unconfigured per the 2026-08-29 decision). And the two Default-ruleset booleans are decided: branches must be up to date before merging (on — a stale head re-proves itself against the moved base before it can land) and required thread resolution stays off. "Settings still worth a look" is removed from TODO.md and the attack sequence's Phase 2 is struck. -
Every pull-request check now runs only when its component actually changed. The 14 checks stay — every context the ruleset requires still reports on every pull request — but
ci.yml's six jobs and CodeQL's three analyses adopt the filtering-inside-the-job patterniac-validate.ymlproved under T-523 (a trigger-levelpaths:filter on a required context leaves the PR waiting on "Expected" forever, so the job itself diffs against the base and skips its expensive steps, still posting success). Each filter names the component's real dependency set, not just its own tree: functions tests readinfra/Terraform source,.azure/api-surface.jsonand thewiki/Blog-Machine.mdgrammar contract; scripts tests pin.github/workflows/andinfra/roles; CodeQL filters by language file extensions. A docs-only pull request drops from six installs, a frontend build and three CodeQL analyses to nine ~10-second no-ops; pushes tomainand the weekly CodeQL schedule still run everything, so the merged combination and newly published queries are never skipped. The decision, its rejected alternatives and its accepted risks are ADR 0026; a public write-up of the pattern was staged asBlog-FinOps-01-CI-Refund(that draft has since moved to the owner's private vault). -
The code-review skill moved to
.github/skills/code-review/, and its consumer is now GitHub Copilot code review. Owner decision, 2026-09-01: Copilot reads agent skills only from.github/skills/, Claude Code only from.claude/skills/, and rather than keep two drifting copies the one canonical copy lives where the automatic PR reviewer finds it. The trade-off, accepted explicitly: Claude Code sessions no longer auto-load the skill (an agent can still be pointed at the files when asked to review). The structure validator's Markdown allowlist gains.github/skills/with the reasoning inline; the #316 entry above describes the skill's content, whose location this entry supersedes. -
The Static Web Apps deployment token is minted per run instead of stored (T-727, #296). The decision was "move the SWA deploy to OIDC", which cannot mean what it sounds like:
Azure/static-web-apps-deploycannot authenticate with a federated credential at all (azure/static-web-apps#1304 is an open request for exactly that). So the token stays and the storage goes. The deploy job asks ARM for it under the federated identity the repository already uses, masks it, and it lives for one run.
Retired: the swa_token Terraform output, visible on the HCP Terraform
Outputs tab to anyone with state read, and the long-lived
AZURE_STATIC_WEB_APPS_API_TOKEN GitHub secret, deleted by the owner on
2026-08-31. No stored, non-expiring credential remains in this
repository's secrets. Not retired, and the header says so where the output
used to be: the token still exists in Terraform state as an attribute of
azurerm_static_web_app.hcw, which no output block could ever have changed.
It cost no new federated credential. The deploy job already declares
environment: production and github_deploy already held the matching
subject. Microsoft.Web/staticSites/listSecrets/action is an action rather
than a read, so Reader cannot express it and the built-ins that carry it
also grant write over the site; the custom HCW Static Web App Deployer role
grants the single action, assigned to the one site.
- Three documents said the
hcw-azureworkspace was CLI-driven with no VCS connection (#297).terraform -chdir=infra apply, which this repository instructed the owner to run, answers "Apply not allowed for workspaces with a VCS connection".TODO.mdhad been wrong about this twice in opposite directions.
The root cause is worth more than the correction. HCP Terraform's workspace
Description is free text sitting beside the real settings, validated by
nothing, and it read "CLI-driven; no VCS connection". That sentence is what
was read — while the entry claimed it had been read off the configuration. A
description that contradicts its own workspace reads exactly like a setting.
Corrected in the workspace and in TODO.md, wiki/Cutover-Runbook.md and
.github/workflows/tfc-plan-check.yml.
- Repository trackers reconciled against merged main (2026-08-30). The
TODO.mdstatus date and self-count now agree with its August 30 evidence; its owner-action table records the two proven timers rather than saying nothing is armed; and its recovery baseline now names the implementedContinuous30DaysCosmos backup, RA-GRS content storage, and remaining LRS Functions host storage. GitHub issues #127, #180 and #231 were re-read against current Terraform and.azure/api-surface.json: their unfinished decisions remain open, but superseded file anchors, endpoint counts and recovery baselines no longer describe old code as current state.
The live Default branch ruleset was also read through GitHub on 2026-08-30,
rather than inferred from workflow files: it targets ~DEFAULT_BRANCH,
blocks deletion and non-fast-forward updates, requires pull requests and all
12 documented status contexts, and has no bypass actors. Its two remaining
hardening choices — strict/up-to-date status checks and required review-thread
resolution — are both currently off and are recorded in TODO.md for an
owner decision. The production environment's branch restriction could not
be read through the integration, so that existing owner verification remains
open instead of being reported as proven.
- The Telegram webhook is registered against Azure, and the tracker was wrong
about it (T-526 closed, 2026-08-28).
getWebhookInforeturnshttps://api-azure.hybridcloudworks.com/api/telegram/webhookwith zero pending updates, and/helpanswers in the chat — the acceptance criterionTODO.mdset for itself. The Phase 5 approve-by-reply loop is live, which makes the whole Blog Machine Telegram path usable: a forge_ready notification with a signed staging link,/approve {id}as a reply, and publication through the full gated pipeline. The inline approve/reject buttons deferred to the backlog are unblocked by the same fact.
Nothing was re-run to close this. It had already been done, and three
documents went on describing it as pending: TODO.md called it "the one
deadline on this list" and a countdown against the GCP deletion,
Migration-Plan.md §6 step 6 marked it OPEN, and its risk register read "High
— now a deadline". It surfaced only because 04-telegram-webhook.ps1 -Mode
Show was run to start the work.
That step's own text warned it was "the one that will be forgotten". It was right about the failure mode and wrong about the state — what got forgotten was that it had happened. A tracker overstating urgency is not harmless: it spends attention on finished work and, by being wrong in the direction of alarm, teaches a reader to discount the next alarm.
Three defects in the cutover scripts were found by running them, none of
which reading had caught. Every script that opens a firewall window addressed
the vault as az keyvault network-rule add --name <vault> with no
--resource-group, which makes az resolve the group by searching the
subscription; that search fails where az keyvault show --name succeeds, and
reports The Vault 'kv-site-prod-cus-01' not found within subscription —
which reads as "the vault does not exist". Name plus resource group is an
unambiguous address with no search step. The vault read then failed
ForbiddenByRbac, because management-plane and data-plane rights are separate
and this estate deliberately grants the operator only the first (REVIEW.md
§4.6 records exactly that response and calls it the correct posture) — so
06-seed-secret.ps1 now checks for a data-plane role at the management plane
before prompting for a credential and before opening anything, rather than
discovering it at the write.
- The four planning documents are reconciled to their own conventions
(2026-08-28). Two rules, applied consistently for the first time. TODO.md
and REVIEW.md carry only open work — completed items are removed once the
entry is here, which is TODO.md's own stated footer rule and which the file
had stopped obeying: it had accumulated
**Closed:**enumerations for the architecture review's 35 resolved findings and a "Critical — CLOSED" section, so a reader could no longer tell what was outstanding without reading past a page of finished work. REVIEW.md shed the executed migration-era teardown, the two closed live-confirmation bullets and the completed apex-DNS row for the same reason. Architecture-Plan.md and Migration-Plan.md keep every entry and strike it through — they are archived records whose value is the reasoning, so deleting a decision would delete why it was made.
Four things surfaced from doing it rather than being the point of it, and the largest is the one this rule is for: six merged pull requests had no changelog entry at all — #234's availability probe and #243–#248's post-program backlog work. The removal pass caught them because removing an item from TODO.md requires checking that CHANGELOG.md has it, and that check had been skipped while the completed items were merely being marked closed in place. Both entries are above. The open-item counts in TODO.md were also wrong — Low read "5 of 15 closed" against a list of seven, and the total was 32 against a real 30. Migration-Plan §6's rollback paragraph still promised "rollback is DNS for as long as Firebase remains deployed", which the owner's decision to delete GCP rather than soak had already reversed; it now records the inversion, because that decision is exactly what puts a deadline on T-526. And two of the plan's eight verification gates — the scheduled-job proof and the cost gate — turn out to be the only unmet exit criteria of the entire migration, which was not legible while they sat in a list whose met items were unmarked.
- The apex serves the Azure site (T-517 closed, 2026-08-28). The cutover
this repository was built toward is done:
hybridcloudworks.com— the canonical hostname, the one host that was still Firebase — now resolves tocalm-ground-0d0e6a010.7.azurestaticapps.netand serves the Static Web App. Evidence, in the order it arrived: the owner-supplied Cloudflare zone export of 2026-08-27 23:47 showed the apexCNAMEat the SWA with no Firebase record remaining anywhere in the zone (the Runbook §3c repoint); the owner then verified serving on 2026-08-28 — the acceptance criterion this tracker holds cutovers to, because DNS state is desired state and T-513 is the recorded case where the two disagreed.
Two owner decisions recorded with the close-out. First, the DNS rollback is forgone: rather than holding the Firebase deployment through the Runbook's one-week soak, GCP is scheduled for deletion. Second, that decision converts the Telegram webhook re-registration (Runbook §3d) from a dormant follow-through into a deadline — the bot's webhook still points at the old Cloud Functions URL, and once GCP is deleted it goes quiet with no error anywhere in Azure. Tracked as T-526, to be run before the deletion.
The Runbook §3c soak criterion ("a full week including every scheduled job") could never have completed as written — no timer is armed (T-518) — so the owner's decision also resolves a dead-lock this tracker had flagged between the two items.
- The two
data-migrationfederated credentials are retired (T-524 closed, 2026-08-26).infra/oidc.tftrusted six OIDC subjects and now trusts four. The pair granted no permission of its own — a federated credential decides which subject may act as the deploy identity — and with the production-write grants already revoked, adata-migrationtoken inherited the same reduced role set a branch token gets. What it removed was a standing trust relationship for a job that cannot run.
Held back from the earlier cleanup deliberately: retiring a trust relationship is an identity change rather than a Terraform tidy-up, which is why the remediation branch escalated it instead of deleting it. The owner authorised it on 2026-08-26.
Validated before the change, not after. Nothing in .github/workflows
names that environment; its only consumer, migrate-data.yml, was deleted in
59e471b. Of the four workflows that call azure/login, one declares
environment: production and three declare none, so they present the ref
form — deploy-azure-frontend.yml names an environment but deploys through
Azure/static-web-apps-deploy and never logs into Azure at all.
scripts/oidc-subjects.test.mjs was then run against three variants of the
deletion, because the check that matters is the one that fails:
| Variant | Guard |
|---|---|
| Both credentials removed | passes |
| Only the name form removed | fails — no immutable-ID-form credential |
| Branch pair swept up with them | fails — names all three ref-form workflows |
Both forms went together for the reason the second row states: one without the other is half a credential and fails on whichever form the token happens to carry. If a migration workflow is ever rebuilt it needs both back.
-
The migration-era rehearsal estate is destroyed and the three production-write grants are revoked (B6/B7, applied 2026-08-25). The apply reported 3 added, 2 changed, 92 destroyed. The destroy count matched the authorisation in
REVIEW.mdexactly — 90 real destroys plus the 2 azapi resources replaced on every apply — which is the number that mattered, since the record insisted on approving against addresses rather than a count. The adds and changes came in below the recorded 17/5, and that is not a short apply: that figure was written before any of it ran, most of those adds were the alert rules, and #218 and #219 had already created them (ten of thirteen targeted resources, then the remaining three after ARM rejected them at create time). By the time this run planned, they were no longer adds. Everything was verified after the apply rather than inferred from the plan:rg-db-site-sbx-cusno longer exists; all four alert rules are still present and enabled; and the deploy identity is down to four operational roles — HCW Cosmos Container Definition Writer on the production account, Storage Account Contributor and Storage Blob Data Contributor onstsitefuncprodcus01(the Functions host account, needed for the deploy firewall window), and Website Contributor on the Function App. The three revoked grants were scoped todbs/hcwand tostsiteprodcus01, the content account; none of them appears. What this cost is already recorded: with the grants gone the deploy identity has no write path into the production Cosmos database or the content account, so the delta import is retired for good. The twodata-migrationfederated credentials survive —federated_subjectsstill emitsenvironment:data-migrationtwice — and remain an owner decision (T-524). -
REVIEW.md§4.10 listed the wrong number of Terraform outputs. It said twenty-three and omitteddeploy_principal_id, because it was assembled by readinginfra/outputs.tfalone while three outputs live ininfra/oidc.tf. There are twenty-four, and the section now lists them by file. Corrected against the apply's own output block, which is the only listing guaranteed to be complete. Recorded rather than quietly fixed because it is precisely the drift Part 4 exists to prevent, and it was introduced by the change that restored Part 4 four commits earlier. -
The IaC checks are required to merge, and the three orphaned repository variables are gone (T-523 owner half, T-525, 2026-08-25). Ruleset
20680114now requires 12 contexts rather than 10, the two additions beingfmt, validate, tflintandTrivy IaC misconfiguration scan. Every "CI enforces this" line inCONTRIBUTINGis now true when nobody is watching, where before a branch with a red Trivy run merged exactly as easily as one with a green run. The ordering the item insisted on held: #220 removed thepaths:filter from thepull_requesttrigger first, and PR #221 — which touches onlytooling/agent-registry.ymland no infrastructure at all — then confirmed the skip path end to end, itsfmt, validate, tflintjob reporting green in 22s withDetect infra changessucceeded and all six Terraform stepsskipped. Had the ruleset been changed first, that same pull request would have been unmergeable.COSMOS_SCRATCH_ENDPOINT,STORAGE_SCRATCH_ACCOUNTandSCRATCH_RESOURCE_GROUPwere deleted in the same pass;gh variable listnow returns 20 names andREVIEW.md§4.2 lists exactly those 20, so the inventory and the live repository agree with no residue to reconcile. The variables named resources that will stop existing when the rehearsal teardown applies, and a value nothing reads is a value the next person assumes is load-bearing. -
The frontend is on ESLint 10 (D-001 closed). The item said two plugins blocked it, on the strength of their declared peer ranges. That was half right and the wrong half was load-bearing, so it is worth recording what the block actually was.
Dependabot's bump failed at npm ci, not at lint — an ERESOLVE refusal from
eslint-plugin-jsx-a11y's eslint@"…|| ^9" peer range. That is metadata, and
npm overrides pinning both plugins' eslint peer to $eslint clears it.
What remained was one real incompatibility: every rule that consults the React
version died with contextOrFilename.getFilename is not a function, because
ESLint 10 removed context.getFilename() and eslint-plugin-react calls it
while DETECTING the React version. Detection only runs when
settings.react.version is the literal 'detect', so supplying the version
skips the removed API entirely. The config now reads it from the installed
react/package.json rather than pinning a literal, so an upgrade cannot leave
the linter reasoning about the wrong React.
Verified beyond a green run, because a plugin that silently loaded no rules
would also look green: a probe file confirmed react/jsx-key,
react/no-unescaped-entities, jsx-a11y/alt-text and
react-hooks/rules-of-hooks all still report on ESLint 10, and npm ci — the
command that actually failed — now succeeds.
One rule stays off, for a new reason. jsx-a11y/label-has-associated-control
was disabled because it crashed on ESLint 9; on 10 it runs and reports 20 real
unlabelled form controls. That is an accessibility fix rather than an upgrade,
so it is tracked as A-001 and the config comment now says so — the stale one
would have told the next reader the rule was unusable.
- Listen & Learn spend appears in the portal, and ai_usage has one writer.
The Usage tab has read that container since the port; until now only the AI
playground wrote to it, so a Listen & Learn run — the second thing here that
spends money on a model — would have been invisible. Each run now records a
row per model call: one for the script, one for the synthesis, tagged
listen-and-learn:script and listen-and-learn:audio.
The writer moved into ai/usage.js and ai/proxy.js now uses it, because the
Usage tab does its arithmetic client-side over whatever rows it finds — a
second writer with a slightly different shape would not error, it would
silently total zero. Recording is best-effort by design, and a test pins the
regression that made it otherwise: pricing a row used to happen outside the
try, so a caller passing an ai without getCostEstimate threw a TypeError
that propagated out and failed the episode whose cost it was recording.
TTS rates are in COST_TABLE from the published paid-tier pricing read on
2026-08-24 — gemini-2.5-flash-preview-tts at $0.50 in / $10.00 out per 1M
tokens, the other two at double that, which is why the flash model is the
default. Token counts come from the API's own usage object; when a response
omits it the audio count is derived from duration at the documented 32
tokens/second and the row is flagged estimatedTokens, which the portal shows
as "est." so a derived figure is never read as a billed one. On those rates a
nine-minute episode is about $0.17 and a five-area certification about $0.87.
The tab gains a Breakdown by Feature table beside the provider one:
provider answers "which vendor", which is useless when one vendor serves
several features at rates an order of magnitude apart. The Listen & Learn page
also reports the run's own cost when the job finishes. A test holds the tab's
source labels against the backend's USAGE_SOURCES so a new source cannot
ship as a raw slug — the same drift guard DEFAULT_PROVIDERS already has.
- QueuePage.jsx is decomposed (T-412). 1,310 lines became a 320-line page
over queue/itemHelpers.jsx, queue/QueueList.jsx, queue/constants.js and
queue/useQueueActions.js. The hook is the reason for the split: the bulk
paths transition many documents one at a time and each partial failure has to
be attributed back to its own card, and that code could previously only be
reached by rendering four hundred lines of card markup. It now has 22 tests
covering the partial-failure paths — a run that half-works removes exactly the
documents that moved, leaves the ones that did not, and writes a reason under
each — plus the paging loop's zero-count guard and the rejected-filter
refusal. Behaviour is unchanged with one fix found by the move: handleConfirm
was useCallback(..., [confirmTarget]) while closing over handlers rebuilt
every render, so it could act on items and selectedIds as they were when
the modal opened. It is no longer memoized; the dependencies changed every
render regardless, so nothing was gained by it.
- Publicly readable blob containers now declare their writer.
PUBLIC_MEDIA_CONTAINERS ⊂ UPLOAD_CONTAINERS held only because every public
container happened to be one people upload to. Listen & Learn audio is written
by a job, so GENERATED_MEDIA_CONTAINERS names that category and the test
asserts each public container has exactly one declared writer and that the two
sets are disjoint. Satisfying the old relation would have meant opening the
episode container to the admin upload route, where any editor could put an
arbitrary file behind an anonymous URL.
- Every backend dependency has a live consumer (T-407); nothing was
removed. The item asked whether
cheerio,rss-parser,google-auth-libraryand the other non-route packages infunctions/package.jsonstill had one. All of them do, and each consumer is reachable from a registered function:cheeriofromcms/content-quality.js,content/scrape.js,rss/feeds.jsandsanitize-html.js;rss-parserfromrss/ingest.js(thefetch-rss-feedsjob and thesyncRssFeedstimer) andtimers/podcasts.js;google-auth-libraryfromcloud-tools/pricing/gcp.jsvia the pricing index; andturndown,jsonwebtoken,jwks-rsa,@aws-sdk/client-pricingand the four Azure SDK packages from token verification, scraping, pricing, Key Vault, Blob and Cosmos. Recorded rather than closed silently, because "no packages were removed" is the finding. - The remaining upstream feature candidates are evaluated (T-410). Measured
against the Site-Main checkout at
088f458, the same baseline the T-409 delta used: draw.io hotspot tooling is the one candidate worth porting.lib/drawio/parseDrawio.jsandlib/drawio/hotspotGeometry.jsare 258 lines of pure client-side XML parsing with no Firebase coupling and an upstream test and fixture;DiagramPanel's only backend seam is an image upload, which maps onto the existingPOST /api/cms/uploads/{container}. It replaces manual hotspot authoring — todayArchitectureReviewBoardrequires each hotspot's coordinates and label to be typed by hand — with generation from an uploaded.drawiofile, andInteractiveDiagramalready consumes the resulting shape. Admin queue improvements split in two. The bulk select, bulk reject, bulk delete and confirm-modal paths already exist in this repository's 1,310-lineQueuePage.jsx; the upstream delta is a decomposition into five modules with tests, plus two additional actions (bulkApprove,bulkForge). The decomposition is worth doing against this repository's own file rather than porting upstream's, which is written against Firestore-era helpers. The Architecture listing pages are not worth porting as they stand.ArchitectureDesignsPageandArchitectureCreatePageare 163 lines between them, but they are thin wrappers overContentReviewBrowser(744 lines) and thecomponents/admin/browser/subsystem,useAdminBrowserandlib/adminBrowser— roughly 1,500 further lines — and their data seam,fetchContentList, is built from Firestorewhere()clauses. That is the whole-branch shape T-410 was written to refuse, andEditorListPagealready filters admin content by type,architectureincluded. Which candidate is actually built is a product decision and now sits in TODO.md. -
The ESLint 10 upgrade is still blocked, and by fewer plugins (D-001). Re-checked against the registry on 2026-08-24:
eslint-plugin-react-hooks7.1.1 and@typescript-eslint/eslint-plugin8.67.0 now declareeslint@^10.eslint-plugin-react7.37.5 still caps its peer range at^9.7andeslint-plugin-jsx-a11y6.10.2 at^9, so the frontend stays on the ESLint 9 line. -
Retired the completed migration surface and reset the repository around the HybridCloudWorks website. Removed the old Firebase Functions package and Labs agent, the completed Firestore/GCS migration workflow and one-shot import tooling, the disabled infrastructure-delivery workflow, and unused Firebase hosting, scaffolding, screenshot, generator, and debug scripts. Retained the Azure container specification because Terraform still consumes it, and retained active Azure operations, CI, deployment, and smoke tooling.
- Reconciled repository documentation.
README.mdnow documents the website's features, architecture, local development, and delivery model;TODO.mdcontains only engineer-resolvable pending work;REVIEW.mdcontains only human-owned access, approval, credential, and live-verification items; and both plans are archived records rather than active instructions. - Moved Wiki-as-code to
wiki/. The sync workflow and repository policy now use the rootwiki/staging directory, and the Wiki home/sidebar point at current website and Azure documentation. All repository workflows use GitHub-hosted runners. - Removed remaining active cleanup residue. Deleted the unused Functions
parity contract and duplicate Markdown bug template, updated current
infrastructure metadata and operator helpers to reference the website state,
and allowlisted the repository's reusable
.github/templates/directory. -
Retired the unused frontend Firebase platform surface. Removed the old
frontend/firebase.json, Firebase rules/indexes/storage files, GCP Terraform configuration, and dangling test/lint exclusions; the current frontend package now validates only the livesrc/tree. -
migrate-data.ymlrewritten. Dispatch-only;id-token: write;environment: data-migration; modespreflight | inventory-gate | export-dry-run | rehearse | verify | storage-inventory | storage-rehearsewithtarget∈scratch(default) |productionand a hard refusal of write modes against production. Step order is a correctness constraint:npm ci, the Site-Main checkout and the Cosmos probe all run beforegoogle-github-actions/auth, because the GitHub OIDC token it exchanges lives five minutes. Per-run storage firewall window withalways()cleanup, mirroringdeploy-functions.yml.COSMOS_DATABASE: hcw. Inputs reach the shell throughenv, never interpolated intorun:. COSMOS_ENDPOINTis a repository variable, not a secret. It is a public URL and a non-sensitive Terraform output; as a secret it was masked in logs and unverifiable in the UI.set-github-variables.ps1now seeds it as a variable and deletes the old secret; both consuming workflows readvars.COSMOS_ENDPOINT. The script also takes-GcpWorkloadIdentityProvider/-GcpServiceAccountfor the two WIF identifiers, and seedsSTORAGE_ACCOUNT/STORAGE_RESOURCE_GROUP.- Migration scripts share one credential path.
scripts/lib/cli.mjsgainsconnectFirestore()(ADC, explicitprojectId),connectCosmos()(DefaultAzureCredentialonly),connectBlob(),classifyCosmosError(),writeReport()(full report + publishable.summary.jsonsibling) andshowSamples(); the migrator, preflight and verifier use them. The manifest is re-baselined at Site-Main088f458withazure_architecturesandazure_frameworksadded asprobe— not provisioned, so the generated container spec is unchanged. Migration-Plan.mdrebaselined against Site-Main @088f458. §0 is now donor/recipient with a pinned baseline instead of "reconcile weekly" (the two repositories finished Phase 1 in incompatible directions); §2 carries real status; §4 carries the measured inventories — the six HTTP handlers over the 230 s Flex cap, the 16 timers with NCRONTAB and zone, the 11 triggers with change-feed disposition and the three delete paths the feed cannot deliver, and the Vertex-default finding; §5 is rewritten around the tooling defects, the rehearsal estate, the five dispositions, the storage manifest and the public-repository rule; §6–§9 updated to match. The two links to the wrong GitHub org are gone.- Every image render site routes through
resolveMediaUrl()(T-318, sixteen files, commit09154ad). Stored site-relative/api/public/media/...paths now resolve against the Cloudflare API host, which the origin lock made the only working shape; absolute legacy URLs pass through untouched. oidc.tf's "deliberately NOT granted" note now says what is true: the migration does use the deploy identity, on the scratch account at database scope, and holds nothing extra on production whilemigration_writer_enabledis off.-
Every Terraform output renamed to the 2-word standard (workload owner directive, 2026-08-18:
github_deploy_client_idwas four words). The standard now explicitly covers outputs — they are operator-facing, read off the state backend's Outputs tab — and states that casing follows the language while the word count does not: UPPER_SNAKE for GitHub variables, lower_snake for HCL. Outputs that feed a GitHub variable now mirror it:client_id↔CLIENT_ID. Headline renames:github_deploy_client_id→client_id,github_deploy_federated_subjects→federated_subjects,function_app_default_hostname→function_hostname,static_web_app_default_hostname→swa_hostname,app_insights_connection_string→insights_connection,ci_runner_job_name→runner_job. Two genuine duplicates removed:azure_functions_hostnameandazure_swa_hostnamereturned values identical to their non-prefixed twins and were folded into one output each. One genuine collision resolved with a deliberate third word — the Function App and the deploy identity both expose a principal id, soapp_principal_id/deploy_principal_id. No resource address,azurerm_*argument, or state-bearing name changed;terraform fmtandvalidatepass. Terraform input variables were deliberately NOT renamed: they must match HCP Terraform workspace keys exactly and several are set live, so they are a coordinated setting-plus-code change — filed as TODO T-507 with the full proposed table. App settings read viaprocess.env,VITE_*andGITHUB_TOKENare contractual and untouched. Theiac-repo-standardizeragent now sweeps every.tffile rather than a curated list — the gap that letci_runner_job_namesurvive the first pass. (PR #117) -
deploy-infra.ymlrewritten while remaining hard-disabled — the prototype workflow applied with-auto-approveon every push tomain, masked failed plans withcontinue-on-error, and used unpinned actions. The replacement isworkflow_dispatch-only, runs in aproduction-infraGitHub Environment for required-reviewer approval, starts an HCP Terraform run whose apply is confirmed in TFC where the state lives, and keeps theif: ${{ false }}gate until production applies are authorized. -
Self-healing computed properties —
.github/workflows/ heal-computed-properties.ymlre-appliescp_sortDateon any push touching the Cosmos Terraform or container manifest, and every six hours — because Terraform applies run in TF Cloud on their own clock, a push-time heal can itself be overwritten, so the schedule is what guarantees the wound closes. WithPUBLIC_LIST_SQL_ORDER=1live, a wiped property breaks the public content list, which is why this is automation rather than a runbook note. The OIDC deploy identity gains Cosmos Data Contributor scoped to exactly thecontentandblogscontainers (infra/oidc.tf) — the one documented exception to its deliberate no-Cosmos posture, and the healer fails loudly on a schedule until that assignment is applied. (TODO.md T-206 follow-up) -
cp_sortDatecomputed property + flag-gated ORDER BY — T-206's last step, authored as operator tooling.scripts/apply-computed-sortdate.mjs --inspectreports non-ISO date values (the evidence gate),--applyadds a computed property that resolves the five published-date aliases server-side with a total fallback, andPUBLIC_LIST_SQL_ORDER=1then makes the public list's TOP window return the newest N documents instead of an arbitrary N. A computed property rather than a materialized field: no backfill, no write-site maintenance, and it cannot be missing — which is what makes ORDER BY on it safe under the module's own rule 2. The azurerm provider cannot express computed properties, so the script is the applier and the manifest records the drift hazard: a terraform apply that updates the container wipes the property. Applied to the live containers and flipped on 2026-08-14; the deployed smoke test passed against the ordered window, closing T-206 entirely. (TODO.md T-206) -
Deployed smoke test —
scripts/smoke-deployed.mjs, the runnable half of the work order's top item. Tier 1 exercises the anonymous surface with no side effects: the public filter and T-206 projection (asserting the eight excluded body fields stay excluded andexplanationdoes not false-alarm), guard liveness on admin RPCs, CORS refusal and preflight, negative-cache headers, the health endpoint's non-disclosure, and that the seventeen notImplemented RPCs still 404. Tier 2 (--cosmos) executes the one assumption nothing has executed: that a failed Cosmos patch predicate surfaces through the JS SDK as code 412 and a missing document as 404 — the submission quota's correctness rests on it; it writes a single smoke-prefixed document into the TTL-boundedsubmission_quotacontainer and deletes it. Tier 3 (SMOKE_BEARER_TOKEN) verifies a real token is admitted. Six unit tests pin the script's own assertion helpers, because a smoke test with a wrong filter passes against a broken deployment. -
Anonymous public read API —
GET public/content,public/content/{slugOrId},public/snapshots/{id},public/podcasts,public/feed. The published/draft boundary is enforced server-side, replacing the Firestore security rules that previously performed that role. (#45) - Rate-limited public submission endpoint —
POST public/submissionswith per-type validation, server-side document composition, and a rolling-hour anonymous quota, closing the unauthenticatedaddDoc-into-content path. (#45, #66) - Admin CMS REST surface — certifications, social posts, recordings, speaker
events, settings, images, AI providers / MCP servers, and usage records under
cms/*, all behind the two-gate role guard. (#46, #47) - Authenticated file upload endpoint —
POST cms/uploads/{container}with a container allowlist, blob-path validation, and a server-enforced 15 MB decoded cap, replacing direct browser writes to Firebase Storage. (#62, #65) - Content pipeline RPCs —
createContentItem,updateContentItem,transitionContentStatus, and the publish pipeline, ported with their original dedup, quality-gate, state-machine, and audit semantics. (#43, #44, #59) - Admin identity, snapshots, ops health, content workflow, gallery, labs, and image-prompt RPCs — 34 named RPCs total. (#50, #54, #55, #56, #57, #58)
getLabJobRPC — single lab job with output, replacing the Labs console's per-document realtime subscription. (#65)GET public/platform-health— the landing page's four cloud-status indicators, ported from the Firebase original. Anonymous, with a five-minute cache that is the only thing bounding how hard the route can be made to hit four third-party status APIs; each provider degrades toUNKNOWNindependently and the handler never returns 500, because a dead upstream must not blank the panel. Ported without adding a dependency —axiosandrss-parserstay unreachable. (TODO.md T-316)POST cms/telemetry/legacy-blogs-read— the counter that will justify retiring theblogsfallback container. Guarded atviewer, unlike the anonymous Firebase original: its only caller is an admin page, so anonymity bought nothing and left an unauthenticated write endpoint anyone could use to poison the evidence. (TODO.md T-316)- Anonymous media delivery —
GET public/media/{container}/{*blobPath}, serving uploaded images through the Function App's managed identity with immutable cache headers and conditional-request support. The storage account stays closed to the internet; the container allowlist is a strict subset of the containers uploads may write to. (TODO.md T-105) - Self-hosted CI runner — Azure Container Apps Job with KEDA scale-to-zero, an
ephemeral JIT-config runner image published to Docker Hub with a GHCR mirror,
and a
CI_RUNNERrepository-variable failover switch. (#48) - Labs agent API —
POST agent/claimLabJob,agent/heartbeat,agent/completeLabJob, behind a machine-identity guard (LabAgentApp Role plus alab_agents/{agentId}registry document bound to the credential's object id) that is disjoint from the admin role hierarchy. Claim atomicity is an ETag-guarded write with a lease, so a dead agent's jobs are picked up rather than stranded. (TODO.md T-401) code-revieweragent — carries the Code Review SOP (CODE_REVIEW_PROMPT.md v1.0) as agent 39 of the harness. (#68)-
SOP working documents —
TODO.md,CHECKLIST.md,CHANGELOG.md. -
Firebase-era smoke scripts and nested workflows removed. Three live smoke scripts read
VITE_GCP_FUNCTIONS_URLand built afirebaseConfigfromVITE_FIREBASE_*— none of which the application sets any more, so they could not run — andfrontend/.github/held the source repository's Firebase deploy, E2E and secret-rotation workflows, inert but reading as live configuration. Deleted rather than ported: a half-migrated script that looks runnable and is not is worse than no script, which is exactly what these were. A deployed smoke test is still wanted, written against Entra and the Azure routes. (TODO.md T-317) - Six unused dependencies dropped from the functions package —
sharp,replicate,turndown,@mendable/firecrawl-js,axiosandrss-parser, none of them referenced anywhere undersrc/. (TODO.md T-407) - Frontend decoupled from Firebase. All 34 files importing
firebase/firestore, 5 importingfirebase/auth, and 4 importingfirebase/storagenow call the Azure Functions API. Public pages (#61), admin CRUD (#62), shared config libraries (#63), workflow pages and the editor (#64), remaining admin pages (#65), and submission forms (#66). The production bundle no longer contains a Firebase chunk. - Admin authentication swapped to Entra ID via MSAL —
firebase/autheliminated from the admin surface; MFA is now an Entra Conditional Access policy rather than app-managed phone MFA; the Entra object id is theadmins/{oid}registry key. (#60) - Realtime listeners replaced with polling — the content editor polls its document every 20 s, the Labs dashboard polls a snapshot RPC every 15 s, and the Labs console polls an active job every 5 s. Conflict detection and online/offline semantics are preserved. (#64, #65)
Review.mdrenamed toREVIEW.mdand its scope narrowed to human-resolvable blockers, per the SOP.- Repository structure policy (
scripts/validate-repository-structure.ps1) now requires the five SOP documents, permits them at the root, and rejects case variants of their filenames.
Removed¶
-
The Perplexity key, which nothing read (#1029). Its Function App setting (
PERPLEXITY_API_KEY, a Key Vault reference), its secret catalogue entry, its Integrations card and directory row, and its service docs page are gone; the AI router never implemented Perplexity. Kept on purpose: the deprecated-provider lists that clear old AI Engine rows, the pricing table that prices historicalai_usagerows, and the badge and emoji that label them. The tests that used Perplexity as their example of an untestable card now use Azure AI Speech. Thehcw-azurerun drops the app setting; the secret, and three more that nothing reads (GITHUB-APP-INSTALLATION-ID,HOSTINGER-API-TOKEN,GITHUB-APP-PRIVATE-KEY), are the owner's to delete afterwards, one line each indocs/standards/variables-and-secrets.md. The same pass corrects three records: Caddy'sCLOUDFLARE_API_TOKENis set on the lab host (read by name, and proven by the*.labwildcard certificate only DNS-01 can issue); thehcw-labworkspace token stays missing because that workspace does not exist yet; andCOPILOT_REVIEW_APP_PRIVATE_KEYis still absent, which costs Copilot code review nothing. -
Unused files, scripts and tooling removed in a repository cleanup. Every removal was checked from
origin/main: nothing imports, fetches, runs or links to it. The frontend build, all 1,703 frontend tests, lint, the format check and the three validators pass without them. - 636 unused font files (about 66 MB of
frontend/public/fonts).index.cssloads 14 of 650. The rest were never requested, including all of Aptos and Genos:--font-aptosnames the family but no@font-faceloads it. The OFL licences beside Goldman and Turret Road stay. - 20 public data files nothing fetched:
frontend/public/frameworks/*.json(frameworks come from the API) andfrontend/public/finops/architectures.jsonwith its one diagram. - 11 orphaned components and helpers:
- Firebase-era
lib/errorHandler.js; ui/alert,ui/alert-dialogandui/scroll-area, with their two Radix packages;news/ArticlesBentoGrid;- the
ContentHubTemplate,PatternLayoutsandLandingPageTemplatetemplates; performance/LazyImageandperformance/SuspenseBoundary, with their index.Skeletonstays.
- Firebase-era
- Five Firebase-era admin smoke scripts and their
smoke:*npm scripts. They waited for Firebase Auth state and a heading that no longer exist. validate-keyboard-nav.js, which checked a page and route that no longer exist.a11y:auditnow runs the contrast scan.- Two
a11y:fix-*npm scripts that pointed at files that were never committed. - Husky, commitlint and lint-staged. The hooks were never installed:
there was no
preparescript, and.huskysat infrontend/while.gitis at the root. -
Wrong paths fixed in the docs:
- the code-review skill's frontend reference (script paths and the keyboard check);
- ADR 0030 (
content/digest.js, removed in #541); - the migration plan (
infra/scratch.tfstill exists, as the removal record); - the CommonJS marker in
frontend/scripts/package.json.
-
useAuthRedirectLandingis gone, andApp.jsxis guarded against its return (#531). The hook was mounted on every route in the application — including every anonymous visit to a provider news page — so that a regex could watchwindow.location.hashfor an auth fragment that appeared on one.
It existed because redirectUri was window.location.origin, so Entra
returned the browser to the public home page, which had never touched MSAL.
#520 gave sign-in its own route, and the API registration's bare-origin
redirect URIs were cleared on 2026-09-12 — so a fragment can no longer land on
/ at all. It was deliberately kept for one release after #520 rather than
removed alongside it, because a browser mid-redirect at cutover still landed
there.
App.jsx joins the entry points in msal-not-on-public-routes.test.js. It is
on every route there is, so an auth import there is an auth import everywhere,
which is precisely what the hook was. It passes because every page is
lazyPage(() => import(...)) and that walk follows static imports only — the
distinction that matters, since a lazy admin page costs a public visitor
nothing and a static one costs them 236 kB. Verified to have teeth by planting
a static entraAuth import and watching it fail.
The 2026-08-23 incident the hook documented lives on in AuthCallbackPage.jsx,
so nothing is lost with it.
- Three history pages that showed a visitor nothing (#506).
smoke-test-signoff.mdwas a blank form: 37 rows, every value column empty, no run ever recorded. It is not evidence, it is the shape evidence would have taken, and the real cutover evidence is in the migration runbook and the phase-4 record.implementation-plan.mdshared 10 of its 10 headings withimplementation-todo.md.legacy-azure-migration-task-tracker.mdwas the earliest of the three checklists, superseded by both, still asking for an Azure OpenAI resource retired on 2026-08-19.
Runbooks were reviewed and all six are published unchanged, with their
real resource names — owner decision, 2026-09-11. The names are public by
construction: the Function App answers on its azurewebsites.net hostname,
the repository is public, and infra/variables.tf carries the defaults. The
reasoning in those pages is also inseparable from the names.
- The 80-page Firebase-era archive is deleted from the documentation site (#505). It was 54% of the published corpus, it described a platform this workload stopped running on in August 2026, and ADR 0027 had recorded that pruning it was a separate decision. This is that decision.
The measurement is what settled it. Of the 79 content pages, 78 were
reachable from nothing — no ADR, runbook, standard, architecture page or
history page linked to any of them. The archive index's own justification,
that "several ADRs are only legible next to them", was false when checked
page by page: the single apparent match was an ADR quoting a former file
path that happens to share a filename, not a link. mkdocs build --strict
fails on a broken internal link and passes after the deletion, which is the
same fact proved a second way.
It was also where every piece of noise lived. Every mention of a password
and every contact address on the whole site was in archive/ and nowhere
else, all of it in templates and shell examples for the retired platform.
The redaction gate now reads 71 files instead of 151.
Deleted rather than hidden, because a page that is published, unmaintained,
describes a retired platform and is linked from nothing is not history a
reader benefits from — it is noise competing with the 68 pages that describe
the system as it is. Git history keeps every one of them, which is where
evidence of a retired platform belongs. What a reader loses is nothing they
could previously reach by following a link: the Firebase era is still
described where it is load-bearing, in ADRs 0001 to 0007, in history/, and
in ADR 0023 on the estate's retirement.
The guard added hours earlier caught its own allowlist going stale, which
is the clearest evidence it was worth writing. docs/archive/ sat in
no-wiki-pointers.test.mjs's exemption list; the assertion that fails when
an entry matches no tracked file fired the moment the folder went, so the
dead exemption was removed in the same change rather than left as a hole.
- Two workflows and the spent half of
scripts/cutover/, after an audit of all nineteen. Owner instruction 2026-09-08: "if the ps1 is no longer needed, we need to clean that workflow — also any other workflow that is not needed has to go". Each removal below is a workflow that had already finished, or one that could not succeed by its own design. Everything else was kept, including two that looked dead and are not.
validate-deployed.yml — twenty-one days red, structurally. Dispatched
twice on 2026-08-18 and failed both times; never run again. Both jobs fail
for one reason: they run on a GitHub-hosted runner, and through Cloudflare a
datacenter IP is answered by Bot Fight Mode with a 403 while direct to
origin the origin lock answers 403. The surface job asserts
test "$code" = "200" against the apex, so it cannot pass either — the
deployment runbook's claim that it "still runs the DNS, TLS and
frontend-surface job usefully" is contradicted by its own last run, where
that job also failed. deploy-functions.yml depends on exactly this
behaviour: it curls the same URL from a runner and FAILS the deploy on a
200, which is how the origin lock is proven. The checks themselves are not
lost — they are docs/runbooks/edge-dns-verification.md and
node scripts/smoke-deployed.mjs, both run from an operator machine
Cloudflare admits. The script is untouched and still has its own tests.
retire-wiki.yml — a one-shot that ran, whose target no longer exists.
It overwrote all 141 Wiki pages with pointers to the docs site on
2026-09-06 (dry run, then the real one). The Wiki feature is now off in
repository settings — measured has_wiki: false on 2026-09-08 — so its
first step, git clone ...wiki.git, could not succeed if dispatched.
scripts/docs/wiki-redirects.json is deliberately KEPT: ADR 0027 cites it
as the record of what moved where, and that sentence should stay true.
scripts/cutover/05-verify-timer.ps1 and its workspace-query helpers.
The script proved a timer fired at its intended time by reading
Trigger Details: ScheduleStatus from Log Analytics. Two owner decisions
took that away from opposite directions: #321 dropped host.json's
Function category to Warning to stay under the workspace's 0.25 GB/day
cap, so the host stopped writing the line (measured 2026-09-08: 4,179
AppTraces rows in 24 hours, zero containing ScheduleStatus); and #345
closed the gate it served outright — "all 18 timers are armed and the
per-wave observation read is no longer a gate". A tool whose instrument is
switched off and whose question has been answered is not waiting for
better days.
The clock method survives, in the successor. verify-timer-witness.mjs
now carries it: compare a fixed-hour timer's observed firing times against
its schedule and read the offset, which needs no host verbosity at all.
The worked example is fetchPodcastFeeds either side of #416 — fires at
23:30Z and 01:30Z (odd UTC hours, so an even-numbered Central hour) and
then 04:30Z (even, so UTC). The parity flip is the clock change, visible
without a single trace row.
Two CI steps and one test file went with them, said plainly rather than
quietly. repository-policy.yml no longer runs
scripts/cutover/workspace-query.tests.ps1 — a STEP inside
validate-structure, never the job, so the required context
"Validate root and documentation policy" is unchanged.
scripts/powershell-hygiene.test.mjs (5 tests) was deleted: every one of
its assertions read the deleted .ps1 to pin its skip regex against the
JavaScript log lines, so it had no subject left. And
workflow-write-permissions.test.mjs lost its only ALLOWED entry, which
TIGHTENS it: the assertion now reads "no workflow in this repository holds
contents: write" rather than "exactly retire-wiki.yml does".
What was examined and KEPT, so the audit does not get repeated.
heal-computed-properties.yml stays despite the standing "no healers"
principle, because the wipe is still real at source: the content and blogs
containers are still azurerm_cosmosdb_sql_container
(infra/cosmos.tf:243), that resource still cannot express
computedProperties, and PUBLIC_LIST_SQL_ORDER = "1" is live
(infra/functionapp.tf:518), so an unhealed wipe breaks the public content
list. It can go the day the two containers move to azapi_resource.
tfc-plan-check.yml stays: its 2026-08-31 red was the Report step
emitting a verdict, which is what that step is for, not a broken workflow.
verify-alert-state.yml stays: read-only, dispatch-only, green on
2026-08-30, and the only way to see autoMitigate, which is invisible from
the repository and from the TFC run list. In scripts/cutover/,
06-seed-secret.ps1 is named as the break-glass path by
docs/runbooks/deployment-runbook.md and
docs/standards/variables-and-secrets.md; 04-telegram-webhook.ps1 is the
remediation functions/src/lib/secret-catalog.js tells an operator to run
after a bot-token rotation; and 01-entra-spa.ps1 is parameterised for
granting the Admin app role to a named user, which is a recurring operation
with no other tool. None is spent.
-
The
ai_insightscontainer is dropped from the estate (#340). Owner decision 2026-09-05, after its reader was retired in #339 (T-765): nothing ever wrote it, so it held only the documents the 2026-08-21 migration carried across, with no reader. The manifest entry is gone,infra/cosmos-containers.jsonis regenerated (72 containers, from 73), and the containerprevent_destroyguard is lifted for this one apply, because it covers every instance of thefor_eachand cannot be lifted for one. The apply is the owner's to approve, on a plan that must read exactly one destroy; the PR that follows restores the guard. This is the first container dropped from production since cutover, and it is done as its own apply rather than a rider on any other change, the rule T-302 set for cleanup timers andscratch.tfrecords for the sandbox. -
The AI insights panel and its
ai_insightsread path (#339,T-765). Owner decision 2026-09-05: retire.NewsPagerendered the panel only whenVITE_NEWS_ENABLE_INSIGHTSwastrueat build time, and the frontend deploy never set it, so no visitor has seen the panel since cutover; and nothing in this repository ever wroteai_insights, so it could only have shown the documents the 2026-08-21 migration carried across, frozen. Removed:AiInsightsPanel.jsx, theinsightsbranch ofuseNewsDataandfetchPublicFeed, the build flag, and the second query inGET /api/public/feed, whose response no longer carriesinsights(the tests now assert one query, againstrss_cacheonly). The container and its documents are deliberately not dropped here: that is an apply that destroys data, so it is recorded as its own owner decision in TODO.md rather than riding on the next timer apply. -
The editorconfig findings that are real: one BOM, six missing final newlines, and one of them was being regenerated (#588).
.editorconfigscoping is unchanged, because the diagnosis did not support changing it.
What was actually wrong. frontend/src/pages/admin/SocialHubPage.jsx
carried a UTF-8 BOM against charset = utf-8, and six tracked files ended
without the newline insert_final_newline requires — the four .vscode/*.json
files, frontend/public/icons/certs/certification.svg, and
frontend/data/content-manifest.json. #588 predicted "one UTF-8 BOM file and
5 missing final newlines"; the BOM is exact and the newlines are six.
The sixth is the interesting one. content-manifest.json is generated
by scripts/build-content-manifest.mjs, which wrote
JSON.stringify(manifest, null, 2) with no trailing newline — so fixing the
file alone would have been undone by the next manifest run, and the finding
would have come back looking like a regression. The newline is now part of
next rather than appended at the write, so the unchanged-corpus comparison
just above it sees the same bytes it writes and an unchanged corpus still
produces no diff. The committed file is fixed too, so the repository is clean
now and stays clean.
The 221 count is not explained by the hypothesis in #588, and is measured
from a stale scan. That issue expects the bulk to be "JSDoc *
continuation lines and markdown list indentation measured against
indent_size = 2". Counted on main: 23,811 such JSDoc continuation
lines across 800 files, and 572 odd-indent markdown lines. If indent_size
were flagging those, the number would be two orders of magnitude larger than
221, so whatever produces 221 is not that, and rescoping .editorconfig
against that theory would have changed nothing while looking like a fix.
Per the 2026-09-15 note on #588, Qlty has analysed main exactly once
(ccce064a, before #565, #584 and #589), so 221 is a figure from before those
merges. The remaining count is worth re-reading after a fresh Qlty build
rather than chased now.
Verified with the repository's own tooling rather than the scanner, which
could not be installed here (its binary download is blocked): a tracked-file
sweep reports no BOM, no missing final newline and no CRLF. frontend 1902
tests, scripts 409 tests, eslint and prettier --check all pass.
- Two pointers to the timer catalogue and flags still named
main.tf, which has not held them since the split.functionapp.tfwas split out ofmain.tfon 2026-08-29 (T-754) andlocal.timer_catalogueandlocal.timer_flagswent with it; two references did not follow.
The costly one is the enabled_timers validation's error_message. That is
the sentence an operator reads at the moment a cutover apply has just
failed, and it sent them to a file that does not contain the list they have
to edit — next to a validation whose own comment says "a typo here is
indistinguishable from a timer that does not fire, which is the single most
expensive way to be wrong during a cutover window". The other is
schedulers.js's header, which claimed every flag is "false" in
infra/main.tf; it now also says the flags are generated from
local.timer_flags, so the reader learns that arming a timer is an
enabled_timers edit rather than a code change.
Deliberately unchanged: the many other infra/main.tf references across the
repository are correct — the split moved the Function App, observability,
OIDC, providers and variables out, and main.tf still holds Cosmos, storage,
Key Vault and the rest. So is the historical note in variables.tf about what
main.tf did before 2026-08-24. Only the two that point at moved locals were
wrong.
-
Prerendered pages were thrown away and re-rendered on load (#604).
Appheld the idle flag (requestIdleCallback, or a 1500 ms fallback) that defers the theme toggle and toaster. It usually flipped before the lazy page chunk arrived, re-renderingAppwhile the route<Suspense>was still dehydrated. React 19 then client-rendered that boundary and discarded the server HTML, without callingonRecoverableError, so nothing was logged. Measured in Chromium, the first node inside<main>was replaced on AI-102,/azure,/aws,/and/about, and on all six pages with a slow chunk. The idle state now lives in a siblingDeferredChromecomponent that the boundary cannot see, and the node survives on every page, with or without the delay.e2e/hydration.spec.jsnow tags inside<main>rather than the app wrapper, which is outside the boundary. It holds back lazy chunks and covers a certification page; it fails on the old build and passes on this one. -
Five published articles rendered broken hero images, and their link previews were broken too (#518). The Firebase Storage bucket
hybridcloudworks-61e8d.appspot.comis gone. Checked 2026-09-12: the bucket root and all 28 distinct asset URLs still referenced byfrontend/data/content-manifest.jsonreturn 404, verified againstfonts.googleapis.comand the live site as controls, so this is not a network artefact.
normalizePublicImageUrl was believed to be a compatibility shim preserving
images for migrated content — it was protected on that basis. It was not: it
rewrote storage.googleapis.com/<bucket>/<path> into the
firebasestorage.../o/<enc>?alt=media form, which is one dead URL into
another dead URL. Its falsy branch only caught empty strings and videos, not
a 404 at fetch time, so the browser got an <img> with a dead src and
og:image / twitter:image pointed at 404s.
Both hosts are now treated as absent, which is exactly what this function
already did for a video and right for the same reason: every caller's falsy
branch omits the hero, drops the social tags, and falls a card back to its
placeholder. A missing image renders better than a broken one. The same
correction went into AboutPage.jsx, which carried its own copy of the
rewrite.
Three of the five files named as shims were left alone, and that is the
finding. gallery-images.js, cert-image-cleanup.js and fetch-image.js
do not fetch those URLs — they map a legacy URL onto the Azure blob that
replaced it so a delete or cleanup can find it, or skip it as not worth
re-hosting. Deleting those branches would make the mapping fail for any row
whose object was migrated, turning a successful delete into a silent orphan.
Each now says so in place. The rendering side was where the dead URLs
mattered.
This is not a data migration, deliberately. The rows still hold the dead URLs and are now inert; regenerating the manifest from Cosmos would bring them back and change nothing. A test reads the shipped manifest, extracts every Google Storage URL in it, and asserts each one normalizes to absent — so the guarantee holds whatever the data says, including for a row restored from an old backup.
- A 401 from the API carried no reason, so the admin portal had to guess
(#517).
deny(401, …)set onlyContent-Type— noWWW-Authenticate, contrary to RFC 6750 — so an expired token, a rejected token and a token missing the delegated scope all reached the browser as the same bare status code.classifyFailureinferred "expired session" from all three and offered the same interactive re-authentication, which recovers only the first. For the other two it is a wasted redirect that teaches the operator nothing.
This is the case #503's own guard comment anticipated in so many words: "a misconfigured audience, say, where the new token is rejected exactly like the old one." The once-per-tab flag caught it after one wasted redirect; this catches it before.
The RFC's error codes happen to draw exactly the line the client needs.
invalid_token means the credential is bad and signing in again fixes it.
insufficient_scope means the credential verified and lacks a permission, so
re-acquiring it returns an identical token and an identical refusal. A 401
with no credential at all carries no error code, which RFC 6750 reserves for a
credential that was presented and refused. The client now keys on that: a new
UNKNOWN_REASON.CONFIGURATION renders "Admin access is misconfigured" with the
API's own description and no retry button, and it does not spend the one
automatic recovery either.
One header made the difference between working and invisible. The API is
cross-origin, and WWW-Authenticate is not CORS-safelisted, so
res.headers.get('www-authenticate') returned null in the browser however
carefully the API set it. cors.js now names it in
Access-Control-Expose-Headers, on the actual response rather than only the
preflight.
A 401 with no challenge — an older API, or a proxy that strips the header — behaves exactly as it did before, and there is a test pinning that.
A 401 carrying no credential at all sends a bare Bearer realm="". RFC
6750 §3 is explicit that such a response "SHOULD NOT include an error code or
other error information" — there is no failed attempt to describe, and
describing one would only tell an unauthenticated caller how the endpoint
behaves. The client reads the absent code as "sign in", which is right.
The header builder escapes rather than strips, so a description survives the round trip to the client's parser intact; CR/LF are normalised to a space, because a quoted-string cannot carry them and a newline in a header value is response splitting.
The invalid_token description is a fixed sentence rather than the verifier's
own message. verify-token.js says its claim assertions "land in
admin_audit_logs and never reach the client", and the header becoming
readable is exactly when that promise needed enforcing: those messages
separate expired from bad-signature from wrong-tenant, and jwt audience
invalid. expected: … names configuration outright. The client cannot act on
the difference — all of them mean sign in again — so only the audit row keeps
it.
- The same GitHub exam was rendering at two different levels on one screen,
and the Azure catalogue could not have been made right (#496). GH-100,
GH-200, GH-300, GH-500, GH-600 and GH-900 were carried in both
frontend/src/data/azure/certifications.jsandfrontend/src/data/github/certifications.js./educationrenders every catalogue side by side, so GH-200 appeared in the Foundational row under Azure and the Associate row under GitHub simultaneously.
Four of the six disagreed, not one. The issue caught GH-200 because that
is what the index surfaced; GH-100 and GH-500 are Professional in the
GitHub catalogue against Fundamentals in Azure's, and GH-300 Associate
against Fundamentals. Five of the six sat at Fundamentals in Azure — a
batch default rather than a verified rung.
The Azure copy was unfixable, which decided it. That file's LEVEL_META
defines Fundamentals, Associate, Expert and Specialty, so Professional has
no rung there at all: GH-100 and GH-500 could not be stated truthfully in
that file whatever was typed into them. The GitHub catalogue, by contrast,
was verified per exam against each credential's own Microsoft Learn page
three times (#461, twice under #469) and its header records which vendor
pages are unreliable and why. Owner decision 2026-09-11: the six rows leave
Azure, and GitHub exams belong to /github/education. Booking on Microsoft
Learn is a real fact about these exams; it did not make a second copy of the
rows worth keeping.
The six now-unreachable gh-* study-guide outlines went with them —
outlineFor is consumed only by the Azure detail page, and
study-guides.test.js asserts no outline exists for an exam the catalogue
does not carry. eligibleCerts in scripts/update-study-guides.mjs reads
the catalogue, so the generator drops them on its own from here.
Guarded, because nothing could have caught this. Every assertion in
education-catalogues.test.js read one catalogue at a time, so a
contradiction between two of them was invisible by construction. A new test
fails when any exam code is carried by two providers — codes rather than
levels, since the vendors' own vocabularies do not reconcile (AWS
Foundational, Azure Fundamentals, GitHub Foundations are one rung) and
a mapping table would be another thing to keep correct. Verified against the
pre-fix file: it names all six exams and both levels for each.
- The largest catalogue had no
DATA_SOURCE, and the test that asserts one covered the other seven (#496).azure/certifications.jsexported none, and it sat outside theCATALOGUESlist ineducation-catalogues.test.js, so it was the one catalogue that could omit it without failing a build — and did, from #461 until now.EducationIndexPagecarried a documented??fallback so the freshness line would still name a vendor.
It matters most there of any catalogue: Azure is the largest, it is the one
refreshed automatically by update-learn-catalogue.yml, and so it is the one
whose freshness claim is most worth a reader being able to check. The export
now names the Microsoft Learn credentials browse page — the same URL the
fallback used, so the rendered page does not change — the fallback and its
constant are gone, and Azure joined CATALOGUES, which puts all eight under
the assertion. Verified by removing the export again: the suite goes red
naming azure DATA_SOURCE.label.
- A botched find-and-replace had been live on the docs site for two weeks
(#506).
docs/standards/required-inputs.mdopened by saying it had moved "fromTODO.mdon 2026-08-29, when that file was retired and its open work folded into TODO.md" — a sentence that says a file was folded into itself. Later it offered the reassurance that "a citation readingRequired-Inputs §4.5now readsRequired-Inputs §4.5".
The source was REVIEW.md. git show e7271df9 records the rename as
REVIEW.md => wiki/Required-Inputs.md, and that commit's own message warns
"THE BLANKET RENAME WAS THE WRONG TOOL AND I USED IT FIRST." It caught
the validator and the casing guard; it did not catch the prose, and nothing
else could. Repaired from git history rather than guessed.
-
The naming standard told readers the wrong live resource names. It ended with "These are not the live names. Today's estate is
hcw-functions-prod,hcw-cosmos-prod,hcw-keyvault-prod,hcwstorageprodinrg-hybridcloudworks-prod" — contradicting its own centralus section, every runbook, andinfra/variables.tf, which declaresfunc-site-prod-cus-01,cosmos-site-prod-cus,kv-site-prod-cus-01andstsiteprodcus01. Checked against the live estate, not just the code:func-site-prod-cus-01.azurewebsites.netanswers 403 (it exists, behind the SCM lock) andhcw-functions-prod.azurewebsites.netdoes not resolve at all. The grandfathering rule the paragraph existed to state is kept. -
About thirty citations pointed at two root documents that no longer exist.
CHECKLIST.mdmerged intoREVIEW.mdon 2026-08-20 andREVIEW.mdwas deleted on 2026-08-29;variables-and-secrets.mdstill usesCHECKLIST §nas a table column roughly twenty times.
The citations are kept and explained rather than rewritten, because a
mechanical renumber would have been wrong: CHECKLIST was organised by
consumer (§1 Functions identity, §7 CI inputs) and Required-Inputs by store
(§4.1 workspace, §4.6 Key Vault), so §7 does not become §4.7. A note now
says what CHECKLIST was and that its numbers do not carry across. The
citations remain the record of where each value was inventoried when its
placement was decided.
-
Two decision records claimed a reason that no longer held. The Container Apps CI-runner ADR was kept on the ground that
infra/ci-runner.tf"still holds the gated-off resources"; that file,infra/runner-image/andbuild-runner-image.ymlwere deleted on 2026-08-24. The cost analysis and the resource-validation report citedfix/go-live-remediationas a pending branch five times; it does not exist onorigin. The ADR bodies are untouched — the register's rule is that an accepted ADR is immutable — and the notes above them now carry the correction. -
Opaque references now say what they mean. Register rows reading "Give T-519's signal a path" and "Weigh closing T-718 against its cost" state the thing instead of the ticket, and one note explains that the
T-NNNscheme was retired on 2026-09-05 when work moved to issues — so everyT-in an accepted ADR is a citation, not something to look up. -
Five history pages were bare checklists. Four presented unticked July-2026 boxes that read as open work on a site whose front page says the migration finished, including one instructing a move to the GitHub Wiki that ADR 0027 reversed. Each now opens with what a reader learns from it.
-
The front page linked ten operator pages and none of the five generic walkthroughs, which are the only pages written for a stranger and the only ones carrying no estate names at all. It now opens with where to start if you are not the owner.
-
Thirteen references still sent readers to
wiki/, five days after it was deleted — including one inside a production alert (#502). The Wiki was retired on 2026-09-06 by ADR 0027 andwiki/was removed from the repository, but the pointers into it were not, and none of them was a dead link in prose. They were instructions:
monitor-functions-registered.yml told the owner, in the body of the alert
email that fires when the Function App loses registered functions, to read
The failure with no alert in wiki/Alerting-And-Support.md. The one
moment that path is read is the one moment it has to resolve, and for five
days it resolved to nothing. Four files across two packages cited
wiki/Blog-Machine.md as "the cross-package contract of record" — the
document a reader is sent to in order to keep the frontend and backend
module parsers in step. Two more cited
wiki/0025-cosmos-firewall-datacenter-sentinel.md for why the manifest job
opens a firewall window.
Four more sat in Terraform comments in infra/, naming the record for a
firewall rule and an output. All thirteen now point at docs/, and the
alert carries the published URL beside the path so it is reachable from a
phone without a checkout.
Nothing noticed because nothing could. A comment is not compiled and a
shell string inside a workflow is not linted, so a documentation move that
passed every check left live instructions aimed at a deleted folder.
scripts/no-wiki-pointers.test.mjs is the thing that notices: it walks
every tracked text file and fails on a wiki/ pointer, with an allowlist
for the places history must still be able to say the word — the changelog,
ADR 0027 itself, the archived and historical snapshots, and the comments
that explain the migration. A second assertion fails when an allowlist entry
matches no tracked file, so the exemption list cannot quietly rot into a
hole. Verified by reintroducing the alert's pointer: it failed with the file
and line.
Review found the guard's own blind spot, which is the more useful half.
The first version read Markdown, JavaScript, YAML and PowerShell — and not
Terraform, so four live pointers in infra/ sat behind the gap:
cosmos.tf, outputs.tf and two in variables.tf, each telling an
operator which document records why a firewall rule or an output exists.
The same blind spot was in the grep that found the original nine, so a
guard written to catch this class shipped missing a third of it. The
extension list is now broad, and a third assertion fails when a tracked
file type carrying five or more files is never read at all — so the next
language added to the repository is a decision rather than an oversight.
The migration itself was complete, and that was checked rather than
assumed. All 142 Wiki pages as they stood before retirement were matched
to a file under docs/: 137 by name, and the five that are Wiki furniture
rather than content — Home, _Sidebar, the ADR index, the ADR template
and the legacy documentation index — to docs/index.md, the MkDocs nav,
docs/decisions/index.md, docs/decisions/template.md and
docs/archive/index.md. Every page in the live Wiki is already a stub
pointing at the docs site, and the Wiki is disabled: a reader who follows
the old URL is redirected to the repository. The docs site serves all of it
at https://docs.hybridcloudworks.com/, over five spot-checked sections.
-
The Social Hub's "AI Caption" button reported "Failed to generate caption" and nothing else, while the API had named the cause one field away (#498).
generateSocialCaptionanswers{ error: 'Failed to generate caption', message: <why> }, andlib/api.jsthrew onlyerror. So the toast showed the label and discarded the sentence that said whether a provider had no key, a credential was rejected, or the model returned nothing. The same shape that cost #358 two days on Publer — the status shown, the reason dropped — reproduced inside this client. The thrown message now carriesmessageafter the label, guarded so a message that merely restates the error is not repeated; five cases hold it. The underlying cause of the owner's failure is not known from here, and that is the point: the next press will say. -
Listen & Learn's heading was white on white in light mode. The one bare
text-whitethe #497 sweep missed, because the component lives undercomponents/education/and the sweep's file list did not reach it. Fixed the same way and the file added to the guard, so it cannot come back. -
The two secondary links in the Azure detail sidebar now line up with the primary button. They centred their content, so labels of different lengths started at different x; all three are left-aligned with a shared icon column, which is what the owner drew a line beside.
-
The weekly Learn catalogue workflow ends quietly when nothing changed instead of failing at "Artifact not found" (#461 item 3). The refresh job uploads the catalogue only when it changed, but the commit job downloaded it unconditionally, so the first hand-triggered run after #467 (34408439025, 2026-09-09) failed on a catalogue that was already current. The refresh job now exports
changedand the commit job runs only when it istrue, so an unchanged week shows the job as skipped; the tripwire test asserts the gate. -
js-yaml 4.3.1 → 4.3.2 in the frontend lockfile (Dependabot alert 148, GHSA-2883-xcg3-v3hh, high:
maxTotalMergeKeysdid not bound CPU use for empty merge sources). Lock-only:package.jsonalready allowed the patch. -
Listen & Learn is mounted on the AWS certification pages, its "coming soon" copy no longer names GitHub, every provider in the registry is checked for a dated Learn catalogue, and a flag-disabled timer is visible in Log Analytics once per restart (#461, items 10–12). The audit found
ListenAndLearn.jsxtelling readers that study podcasts were "live for Azure, GitHub and AWS" while only the Azure detail page mounted the component: AWS had a detail page without it, and GitHub has no detail page at all.aws/education/CertDetailPage.jsxnow mountsListenAndLearnwithplatform="aws"under the same gate the Azure page uses — the block, and the audio control inside it, render only when published episodes exist — and the backend'sSUPPORTED_PLATFORMSalready acceptedaws, so nothing changed there; a page test covers both the populated and the empty case. The copy now says Azure and AWS (owner decision: honesty over building a GitHub page). Audio stays Gemini TTS; the speech provider code is untouched.provider-coverage.test.jsgains a registry-driven check: every provider inPROVIDER_ALIASESmust have asrc/data/<provider>/certifications.jsoreducation.jsexporting a realDATA_AS_OFand no rowfindStaleStatusesflags, and it names the provider that has none. That wasfinops, whose page carried its own four rows with no date: they are nowsrc/data/finops/education.js, verified 2026-09-09 against learn.finops.org (where finops.org/certification/ redirects) — Practitioner, Professional and Engineer are listed and active, the Engineer row takes the Foundation's name "FinOps Certified Engineer" and its ten-hour sizing, every link points at the page that exists today, and "FinOps for Platform Engineers" was never a certification: the nearest thing, the "FinOps for Engineers" course, reads "Not currently available", so the row isretiredwith that evidence and points at the Engineer credential. The FinOps page renders the "Catalogue checked against …" line and a status badge from the dates, like the other hubs. The AWS detail page's Helmet<title>is one template string, the form the Azure page uses, because the multi-child form pre-renders as an empty title. Andfunctions/src/functions/schedulers.jslogs the first "disabled — skipping" of each timer per process at Warning —host.jsonholds theFunctioncategory at Warning, so the Information line it used to write never reached Log Analytics and a timer whose flag was off left no trace but a shortDurationMs— then drops to Information for later skips, so a deliberately-off timer is seen once after every restart without a Warning per tick;schedulers.test.jsruns the real registered handler twice and asserts one Warning then one Information. - The GitHub, AWS, Terraform, Google Cloud and VMware Learn catalogues
match the vendors again, say when they were last checked, and derive
"retiring"/"beta" from dates instead of a typed field (#461, items 5–9).
The 2026-09-09 audit found every provider page hard-coded and behind:
GitHub had GH-100 on Foundations, GH-300 on Advanced Security and GH-500 on
Administration with no GH-900 or Copilot exam; AWS listed SOA-C02 under the
CloudOps name a year after SOA-C03 replaced it and knew nothing of the
MLA-C01 (2026-09-28), SAP-C02 (2026-11-16), DVA-C02 (2026-11-30) and
ANS-C01 (2026-12-31) retirements or the AI Business Strategist beta;
Terraform advertised the 003 exam retired in January and a Consul exam
HashiCorp retired on 2026-07-15 while omitting both Advanced credentials;
GCP was missing five of Google's fourteen and still called the Workspace
credential Professional; VMware carried the superseded VCAP-DCV Deploy
and no VCP-VVF. Each catalogue now lives in
frontend/src/data/<provider>/with aDATA_AS_OFandDATA_SOURCEthe page renders as "Catalogue checked against … on Sep 9, 2026" — the one freshness claim that is true. AWS's two duplicate lists (landing page andCertDetailPage.jsx) are one file, and/aws/education/soa-c02still resolves throughpreviousSlugs. The pages share the Azure entry'sfrontend/src/lib/certStatus.js, which gains anupcomingstatus (a version announced but not yet deliverable, such as SAP-C03 from 2026-11-17),isIsoDate,describeCertStatus(the badge wording, "Retiring · last day to test Nov 16, 2026 · replaced by SAP-C03"), and three more stale checks; every page reads "today" throughuseToday(DATA_AS_OF)so the prerendered HTML and the hydrating render agree, andeducation-catalogues.test.jsfails the moment any dated row is past (first due: MLA-C01 on 2026-09-29), which is the reminder to re-verify against the vendor. VCTA-DCV is kept because Broadcom's 2024 FAQ names it and no 2026 Broadcom page contradicts it. - Azure Learn statuses are derived from their dates, the ten retired exams
say so, and every landing card opens a real detail page (#461 items 1 and
2). The Azure catalogue (
frontend/src/data/azure/certifications.js) had not been checked since 2026-04-16, so the live page showed AI-900, AI-102, AZ-204, AZ-500, PL-200, PL-500, PL-600, MB-280, MB-335 and MB-700 as "Expiring Soon" ten weeks after Microsoft retired them, twelve GA exams as "BETA · ends Jun 30, 2026", MB-240 as active, and no retirement for AZ-800/AZ-801. Re-verified against the Microsoft Learn credentials browse API and the credential-retirement page on 2026-09-09: those ten plus MB-240 areretiredwith their dates and replacements (AI-900 → AI-901, AI-102 → AI-103); AB-210, AB-250, AB-410, AB-620, AI-103, AI-200, AI-300, AI-901, DP-750, DP-800, GH-600 and SC-500 areactive; SC-730 is retired (Microsoft withdrew it after the beta, no date published); AZ-800 and AZ-801 areexpiring2026-09-30 → AZ-802, and MS-102expiring2026-11-30; AZ-802 (active), AB-650 (beta) and AI-500 (beta) are added; five applied skills are retired with Microsoft's dates. The pages now compute status from the dates at render time (frontend/src/lib/certStatus.js,deriveStatus), render a Retired badge, filter and replacement code, and printDATA_AS_OFwhere they used to claim "scraped weekly", "tracked weekly", "updated weekly" and "Refreshed by Microsoft each month". A vitest fails the day any storedexpiring/betaoutlives its date. The Azure detail page drops its own 15-entry copy (five of them retired, DP-203 gone) and reads the shared catalogue, so all 68 landing links resolve instead of 15; the 68/azure/education/<slug>routes are pre-rendered — with a single-string Helmet title, since the multi-child form wrote<title></title>— and the Listen & Learn block appears only when published audio exists (verified for azure/AB-100). - ElevenLabs no longer reads Listen & Learn, and the Listen & Learn pin no
longer governs the podcast: speech providers are chosen per product (#436,
#432). Owner rule 2026-09-09: ElevenLabs is only the podcast voice —
article and Plaud transcripts to RSS.com — and Listen & Learn audio is
Gemini TTS, generated on demand and stored as MP3, with Azure AI Speech as
the GA fallback. #447 had put ElevenLabs first in one global order read by
every caller, so the paid podcast voice read every study episode the moment
its key landed, and
LISTEN_AND_LEARN_TTS_PROVIDER— also read by every caller — would have pinned the podcast too, which is why the owner's instruction to set it togeminicould not be applied as the code stood.speech/index.jsnow holdsSPEECH_PRODUCTS = { listenAndLearn: ['gemini', 'azure'], podcast: ['elevenlabs'] };resolveSpeechProvider,synthesizeDialogueandestimateSpeechCostUsdrequireproductand refuse to guess without one; each product reads only its own pin (LISTEN_AND_LEARN_TTS_PROVIDER,PODCAST_TTS_PROVIDER), a pin may name only its product's providers, andLISTEN_AND_LEARN_TTS_PROVIDER = elevenlabsfails every run with a sentence quoting the rule. Nothing falls through between products: the podcast withoutELEVENLABS_API_KEYsaves a transcript-only draft whoseaudioErrornames that setting (pinned inlib/podcast/generate.test.jsthrough the real switch), never a Gemini reading, and #447's out-of-credit fallthrough is removed because its only path was the cross-product one.infra/functionapp.tfsetsLISTEN_AND_LEARN_TTS_PROVIDER = "gemini"on the Function App, deliberately Terraform-managed so the rule is stated in the estate and a portal edit is drift (one app setting added;app-settings-secrets.test.jsandsecret-catalog.test.jsstill pass). The 202, the queued line and thespeechEstimateForRunestimate describe the Listen & Learn product only — Gemini or Azure, never ElevenLabs. ADR 0029 §2b records the rule and keeps §2a as history, saying it was written as if ElevenLabs replaced Gemini for Listen & Learn, which was wrong. - Listen & Learn audio generates again: the Gemini TTS provider read the
SDK's
output_audioaccessor, which the REST reply does not have (#458). Every generation since the provider landed ended withaudioError: "Gemini returned no audio for this dialogue"on a 200. The request matched the documented multi-speaker contract; the parser did not match the response.POST /v1beta/interactionsreturns an Interaction object whose audio sits insteps[].content[]as{ type: 'audio', data, mime_type, sample_rate, channels }, with a top-levelstatusand optionalerrors[].output_audio.datais a convenience accessor the Python and JavaScript SDKs put on their Interaction object, and the guide's examples use it; the REST JSON never carried it, so audio instepswas treated as no audio.speech/gemini.jsnow exportsextractAudio, which walks themodel_outputsteps (keepingoutput_audioas a fallback), joins several audio blocks in order, reads a WAV header for its rate and channel count and strips it, and averages stereo to mono for the MP3 encoder. A reply with no audio now says why on the episode card — thestatus, eacherrors[].codeand message, and the shape seen by type only (model_output[text], never the text);failedandincompletestatuses are refused explicitly, the latter because the audio would stop mid-sentence. The default model moved fromgemini-2.5-flash-preview-ttstogemini-3.1-flash-tts-preview(owner request;COST_TABLEalready priced it at USD 20 per million audio tokens, twice the 2.5 flash rate), withLISTEN_AND_LEARN_TTS_MODELstill the override. Verified against the Interactions API reference and the speech-generation guide on 2026-09-09; the module header records what was checked. - Listen & Learn scripts have a portal toggle, the docs build no longer
dirties the tree, and the router says who writes a key verdict. Three
small things left behind by #449, #435 and the docs hooks.
script.jsdeclaredfeature: 'listenAndLearn'behind the injectedgenerate, whereai-call-sites.test.jscannot see it, and nolistenAndLearnentry existed inAI_FEATURES— so the AI Engine page had no switch for it and the router treated the call as ungated. The feature is now declared at a literal call site inlisten-and-learn/generate.js, the same shape aspodcast/generate.js, and the catalogue lists it as Listen & Learn scripts; the source scan and the catalogue tests cover both directions.scripts/docs/__pycache__/*.pycwas tracked and rewritten by everymkdocs build --strict, so every docs session had to check it out before committing; the two files are untracked and__pycache__/and*.pycare ignored. The router's key-verdict docstring named "the Publer client" as the other source of a rejected-key verdict; it now nameslib/key-verdict.jsas the single writer and lists the three reporters that feed it (the router, the Publer timer client, the Publer REST proxy). - A rejected Publer key no longer fails
syncSocialCalendarScheduledevery five minutes, and the API-keys page now says the key is rejected (#358). Measured in Log Analytics on 2026-09-09: Publer answering 401 to a stale key had failed the timer 429 times in 36 hours, 0 successes — 288 exceptions a day, andalert-app-exceptions-prod-cusnever fired, because the only thing that knew the key was wrong was Publer, saying so into a log nobody reads. That is the blind spotsecrets-health.jsnames in its own header: it detects unresolved, not incorrect. A rejected credential is a configuration state, not a transient fault, andcreatePublerClientwas treating a 401 like a 500.
Three things change. The timer's run now returns { skipped: true, reason:
'credential_rejected', status } on a 401/403 after one warning naming
PUBLER_API_KEY / PUBLER_WORKSPACE_ID — the pair travels together, and a
key valid for a different workspace answers 401 exactly like a stale one;
a 500 or a timeout still throws, because those ARE transient. The Publer
client and publerProxy report the verdict for PUBLER_API_KEY through
the path the AI router already had — its onKeyVerdict writer, promoted
from a private function on the router's default instance into
lib/key-verdict.js so the two reporters cannot disagree about what a
rejected credential is — so the red light on
https://hybridcloudworks.com/admin/integrations comes on whichever path
sees the rejection first, and the first success after that — the rotation
— turns it green again in the same worker: a recorded failure re-arms the
once-per-worker success report, which the router's private version had
never done (Copilot review of the PR). And PUBLER_API_KEY carries
probe: 'publer' in the catalogue,
because the page prints "no liveness check for this one" beside any light
nothing reports on, and secret-catalog.test.js holds that a probe exists
only where a reporter is wired. The proxy's response shape and message are
untouched; the Social Hub still reads "Publer answered 401". Klaviyo and
Linkie do not opt in: their 401/403 semantics have not been read, and a
scope-limited key answering 403 on one endpoint and 200 on the next would
flap the light.
- Five education hubs and three detail pages were white text on a white
background in light mode, and had been since they were written. Headings,
card titles and stat values used a bare
text-whitewith nodark:qualifier, whilesrc/index.csssets--backgroundand--cardto pure white in light. A learner on a default iPhone saw a hero and then blank space where every section heading should be.
The fix already existed in the repository and had never been
propagated. azure/EducationPage.jsx pairs its headings as
text-slate-950 dark:text-white and reads correctly; the other five hubs
carried 15 bare instances each and zero dark variants. 102 sites changed,
read one at a time rather than swept: 25 were deliberately left, every
one of them white-on-an-opaque-coloured-button, where white is correct.
Status chips had the same problem in a subtler form. CertStatusBadge used
text-amber-300 and friends over *-500/20 tints, roughly 1.5:1 against a
near-white surface. Each status now carries an *-800 light foreground
with the original as its dark: variant, around 6:1.
Why nothing caught it. The only colour-contrast gate is a Playwright
spec covering one route, and it does not run in CI. It has now been widened
to all eight hubs and the three detail templates, but deliberately NOT wired
in as a required check: /aws/education was already in its route list while
that page was white-on-white, so the spec cannot have been passing, and
making a red gate required would block every PR. It wants a reading first.
What replaces it in the meantime is a test that does run.
pages/education-a11y.test.js reads the nine files and fails when a
text-white token has neither a dark: variant nor an opaque background,
when a status loses its light foreground, when a carousel dot loses its
label, or when a decorative icon loses aria-hidden. Verified to fail by
reverting one heading: it named the file, the line and the fix.
- The education carousels were silent to a screen reader, and their controls
were 10 pixels wide. The pagination dots were
<button>elements with no children, noaria-labeland noaria-current, announced as "button, button, button" — and they are the only route to anything past the fourth card, which on Azure means past the first four of 108. Each dot now sits in a 24-pixel transparent hit area, meeting WCAG 2.5.8 without changing the 10-pixel dot itself, and carries its page number and current state.
168 decorative material-symbols-outlined spans gained aria-hidden.
Because the font renders a ligature, a screen reader had been reading
"workspace_premium", "arrow_forward" and "open_in_new" aloud as words —
and on a slow connection those words are briefly visible too, since the
font loads with display=swap. Every icon-only control was audited first:
the one without an accessible name, Azure's "open on Microsoft Learn" link,
got one rather than being hidden.
-
refreshPlaudTokenno longer disconnects a document that has an access token but no refresh token (#342). It warns and leaves the document as it is; the access token expires on its own schedule. -
05-verify-timer.ps1reported a zero-row answer as a failed query (#336).Invoke-WorkspaceQueryended withreturn $rows, and PowerShell unrolls an array onto the pipeline on return — an empty one unrolls to nothing, so a query that ran and found no rows reached the caller as$null, which every caller reads as "the workspace query did not run", with the advice to checkaz login, the subscription and Log Analytics Reader. None of those was wrong on either occasion it fired: on 2026-09-03 thesyncRssFeeds -Hours 8read after the #321 cut, and on 2026-09-05 the firstcleanupSoftDeletedContentread before that timer had fired. The helper now returns,$rows, so an empty answer arrives as an empty array and reaches the branch that already existed for it — "No invocation rows for these timers" with its two causes — and$nullis reserved for the call that failed. The #327 stderr capture had made a genuine failure legible; this makes the absence of failure legible too. -
The podcast pages had no data, and the
azureone now plays real audio (T-764, #PR). Found 2026-09-02 beside the empty news pages:podcastshad one production writer, thefetchPodcastFeedstimer, and its flag had never beentrue, so every provider podcast page rendered empty and, unlike the news pages, there was no admin job to fill the container by hand. The only way in was Wave 2 ofT-518, recorded in the #332 entry under Added.
Closed on two reads, both from the owner's laptop on 2026-09-04. The
witness: podcasts.updatedAt fresh at the 13:30Z firing. The page:
https://hybridcloudworks.com/azure/audio-architecture renders the episodes,
and the public list behind it carries, for every episode, a mediaUrl on
podbean's media host with mimeType audio/mpeg and a link to the
podbean episode page — so Play and Download reach the file, and Open
reaches the page, which is what the player is built to do.
What this does not change: PODCAST_FEEDS holds exactly one feed, so the
aws, gcp and vmware podcast pages stay empty by configuration. Adding
feeds for them is a content decision, not a defect.
fetchPodcastFeedsreports its failures at Warning and Error, so the #321 cut cannot hide them (#330). The first Wave 2 witness read split:syncRssFeedsfresh at the 02:00 Chicago boundary,fetchPodcastFeedswith nothing newer than the May migration. That FAIL could not be read, because the ingest caught every failure — the feed fetch, each episode, an empty feed — and reported it throughcontext.log, which is Information level and gone sincehost.jsongatedFunctionat Warning. A run that fired and failed looked exactly like a run that never fired. Now a feed that throws logs at Error with the provider and the message, an episode that throws logs at Warning with its position in the feed, and a feed that answers with no items logs at Warning — the state nothing reported before. The rows carry no URL and no title: the provider names the feed (PODCAST_FEEDSholds one per provider) and a title is third-party text. The Information summary line is unchanged.05-verify-timer.ps1does not surface these rows (its filter isExecutedand the skip marker); they are one query away, and they are the one trace the Warning cut still keeps for this timer:
AppTraces
| where TimeGenerated > ago(6h)
| extend cat = tostring(Properties.Category)
| where cat startswith 'Function.fetchPodcastFeeds'
| where SeverityLevel >= 2
| project TimeGenerated, SeverityLevel, Message
- Three scripts exited 0 without running when invoked on Windows (#329).
apply-computed-sortdate.mjs,smoke-deployed.mjsandbuild-content-manifest.mjscomparedimport.meta.urlagainst`file://${process.argv[1]}`, which never matches aC:\...path — sosmoke-deployed.mjsrun from PowerShell reported nothing and exited 0. NowpathToFileURL(process.argv[1]).href, as incheck-deploy-drift.mjs; a spawn-based test asserts each entry point actually fires. - The timer-observation gate was blind for every invocation after 17:59Z on
2026-09-02, and the record explains how (#328). #321 dropped
host.json'sFunctioncategory to Warning to take host verbosity off the daily cap. TheExecutedandScheduleStatusrows that05-verify-timer.ps1reads are Information-level in that category, so the host stopped writing them the moment the deploy landed. The #321 entry below saysHost.Resultswas kept at Information because the timer-observation gate readsAppRequests— but the verify script's own header saysAppRequestshas been empty for the app's whole life and is not the oracle (T-514). The cut kept the table the gate never used and removed the one it did, and neither document mentioned the other.
Found when Wave 2's gates returned nothing on 2026-09-03: the workspace
was RespectQuota, the app was running (the admin RSS job had processed 21
feeds three hours after the deploy), and platformJobSweeper -Hours 24
returned 37 invocations ending at 13:00:00 CDT — 18:00Z, the deploy minute
— then nothing. The first hypothesis, the daily cap, was wrong and is
recorded as wrong.
Owner decision the same day: keep the cut, make the durable side effect the
primary witness. Tracked as T-766, High, because waves 3–6 cannot produce
their own evidence until each timer's witness is settled. The verify script
now warns at the top of its invocation section when host.json gates
Function above Information, so a zero after the cut reads as "instrument
off" rather than "timer dead" — the confusion this repository has paid for
more than once.
-
Wave 1's invocation gate is confirmed, retroactively and by accident (#328). The #323 record says all four gates were observed; the operator had reported them passed, but the run that would have shown gates 3 and 4 had in fact hung on
az's hidden install prompt (#327), so no figures were ever seen. On 2026-09-03, with the extension installed,platformJobSweeper -Hours 24returned 37 invocations, 37 ran, 0 skipped, at exact 15-minute boundaries with-05:00offsets on everyScheduleStatusline — 04:00:25 to 13:00:00 CDT, continuous until the #321 deploy silenced the host. Wave 1 was real. The #323 entry stands, and this is the observation it was missing. -
ContentListingTemplateinvented a publication date for any item that had none (#325). The blog-variant card read{item.date || 'Feb 10, 2026'}, so an item without a date was stamped with that specific day — not a visible placeholder but a plausible, wrong fact, rendered identically to a real one. Found while removing the hardcoded stamps above: dropping thedatefields would have replaced eighteen varied stale dates with one uniform fabricated date, which is worse, so the fallback had to go first.
An absent date now renders nothing. The surrounding row is conditional too —
an item with neither date nor author would otherwise leave an empty mb-2
div above the title — and the bullet separator appears only when it has both
sides to separate. This reaches every caller of the variant, including the
already-live terraform, github and ansible code pages, whose items come
from useCoderCornerData and are not guaranteed to carry a date.
-
A stale count in
frontend/src/main.jsx(#325). A comment on the hydration-mismatch handler described "the 104 pre-rendered documents". The build has written 120 since 2026-08-28, when 16 standalone routes were added and the sitemap moved 104 → 120 with it; the comment was not updated. The number is removed rather than corrected, because the comment's point is that a silent mismatch wastes the pre-rendering, and that holds at any count. -
Two links in
wiki/Required-Inputs.mdpointed at a heading that is not in that file (#324). Both read[Accepted risks](#accepted-risks), and the accepted-risks table lives inTODO.md— so the in-document form resolved to nothing and silently did nothing when clicked. One of them is the only explanation a reader gets for whypurge_protection_enabledisfalse, which is exactly the link worth following. Now../TODO.md#accepted-risks, matching how the other Wiki pages reach the root documents.
Found by an audit of every relative Markdown link and heading anchor in the 74 tracked Markdown files, prompted by a near-miss in #323: a heading was renamed there and its in-document link had to be updated by hand, with nothing in CI that would have caught it. These two were the only dead anchors in the repository.
-
Three stale wiki records corrected before they cost a round trip (#314).
Architecture-Review-2026-08.mdstill read T-714 "OPEN — needs an owner decision" (closed by #296, hydration landed) and T-709 "owner action open" (closed 2026-08-30/31, both channels observed delivering); each block now carries a dated closing status pointing at the record. AndResource-Validation-Report.md§3 still instructed enabling Key Vault purge protection — the exact opposite of the 2026-08-24 accepted risk — and now marks that item superseded with the reasoning, so the next reader does not re-raise a decided question. -
The nightly manifest job opens a pull request instead of pushing to
main(T-726, #304) — and the reason it needed to was not the one given. The entry that landed it said the ruleset listed the Actions token as a bypass actor, that a bypass belongs to the token rather than to a workflow, and that every workflow holdingcontents: writetherefore inherited the ability to push past twelve required checks.
There is no bypass actor and there was none. Reading
/repos/HybridCloudWorks/HCW-HybridCloudWorks/rulesets/20680114 returns
enforcement: active, no bypass_actors, and the rules deletion,
non_fast_forward, pull_request, required_status_checks. The push was not
privileged, it was refused — and had been since the ruleset was last
updated on 2026-08-25. The bot's only successful manifest push is 4b8c36d,
dated 2026-08-23; every scheduled run from 08-24 to 08-29 went green because
the published set had not moved and the push was never attempted.
A workflow can be broken for five days and report success every night, when
the only thing that would exercise the broken part is a change that did not
happen. That is the durable lesson, and it is why the correction is recorded
rather than quietly edited: the wrong version is what justified adding a
stored App private key, and the honest justification is narrower — the refresh
has to reach main, every route to main is a pull request, and a pull
request opened with GITHUB_TOKEN runs no checks.
Corrected in .github/workflows/publish-content-manifest.yml,
scripts/github-app-token.mjs, scripts/open-manifest-pr.mjs,
scripts/workflow-write-permissions.test.mjs and TODO.md. The third owner
action — "remove the bypass actor" — is deleted; there is nothing to remove.
- A 404 from the manifest route no longer reads as "the app itself"
(T-763).
scripts/build-content-manifest.mjssaid "a 403 usually means the per-run origin window is closed or has not propagated; anything else is the app itself." The nightly job failed with a 404 on 2026-08-30 and 2026-08-31, and that sentence sent the investigation at a Function App reporting 121 registered functions and every health row green.
The app was fine. public/content-manifest arrived with #277 at 2026-08-30
02:45 UTC; the last Deploy Functions run was 01:21 UTC on 7fd9f2a, and
git cat-file -e 7fd9f2a:functions/src/functions/public-content-manifest.js
reports the path absent from that commit. The route has never been deployed.
deploy-functions.yml is dispatch-only by a recorded decision, so merging a
route does not deploy it, and #277 shipped a caller for a route it did not
ship.
describeFetchFailure(status, url) is now exported and asserted: 403 names
the origin window, 404 says the host answered and points at Deploy
Functions, anything else is the app. Four tests, mutation-verified. The
deploy itself is an owner dispatch, tracked as item 1 in TODO.md — along
with the gap it exposed, that nothing detects a route present in main and
absent from the deployed revision, because Monitor Functions Registered
counts functions and a count cannot see a missing one.
- The hourly registration monitor is delivered 22% of the time, and its
header said otherwise (T-519).
monitor-functions-registered.ymlasks GitHub for a run at:41every hour. Measured across the 132 hours from 2026-08-26 08:23 to 08-31 20:23 — the workflow's whole life, on a cron that never changed — 29 of 133 slots produced a run. When one lands it is a median 36 minutes late; only 3 of 29 arrived within 5 minutes of:41. The longest window with no check at all was 12.7 hours, and three separate gaps ran over 10. One check every 4.6 hours, from a workflow documented as hourly.
The header had claimed "an hour of detection latency on a personal site is not the binding constraint." The premise was never true on this repository, and the reason it survived is worth keeping: it reasoned from the cron expression rather than from the run history the cron produced. A schedule is a request, not a guarantee, and nothing had ever compared the two.
Not one unlucky workflow. monitor-unresolved-secrets.yml (29 */6 * * *,
added 08-30) has produced 2 runs where about 8 were due;
publish-content-manifest.yml (15 6 * * *) has landed anywhere from 07:05
to 18:37. GitHub documents that schedule is delayed under load and that
delayed runs are dropped rather than queued. Nothing in this repository can
fix that, so nothing here tries.
What changes instead is which half of the pair is treated as load-bearing.
This workflow checks the CAUSE — function count, AzureWebJobsStorage,
RUNTIME_CONFIG_WRITER, firewall posture. The SYMPTOM is watched by
edge/availability-probe, a Cloudflare Worker on a 5-minute cron that GitHub
has no say in; worker.js records success = res.status === 200, so an
unregistered host — which 404s every route including /api/health — is caught
twelve times an hour rather than once every 4.6. That probe's alert is gated
on availability_probe_alert_enabled, which is still false, so today only the
slow, unreliable half is live. TODO.md raises arming it from Medium to High
on that basis, and the workflow header now carries the measurement, the
correction, and the dependency.
The budget argument was wrong too, in the direction that mattered less: it read "~720 minutes a month" assuming every firing lands. At 29 runs per 5.5 days the real cost is about 160. Lowering the cron would buy nothing — delivery rate is the constraint, not the requested rate.
- The manifest route is deployed and the nightly refresh is green again
(T-763, closed 2026-09-01). Deploy Functions run 81 ran at 2026-08-31 23:45
UTC on
b24371e, the first deploy carryingfunctions/src/functions/public-content-manifest.js. Manifest run 11 at 2026-09-01 00:06 then went green end to end —Build the manifestsucceeded where it had answered 404 on 08-30 and 08-31.
Two things that run confirmed beyond the route itself. The commit job reported
"No change to the published set", so the manifest committed on 2026-08-23
already matched what the API serves — the refresh was blocked, but the
pre-rendered data was never wrong. And it was the first real exercise of the
action majors merged in #303: the log shows digest-mismatch: error,
Expected Digest: sha256:dac23a82…, SHA256 digest of downloaded artifact is
dac23a82…, Artifact download completed successfully — upload-artifact v7
and download-artifact v8 working under v8's new strict default, which no CI
job could have shown because neither workflow using them runs in CI.
What is still unproven: because the published set had not moved, every step after that check was skipped, the GitHub App branch included. T-726's path is exercised for the first time on the first run where the set actually changes.
- The reachability alert's window never moved, so the "fixed" rule tolerated
nothing (T-745, re-opened and closed 2026-09-01).
edge_probe_availabilityfires when successful probes in its window areLessThan 3. T-745 was recorded FIXED on 2026-08-28 as "30-minute window expecting 6 results, firing below 3" — and the threshold did move to 3, and the resourcedescription, the inline comment andedge/availability-probe/wrangler.tomlall moved to describe a 30-minute window.window_durationstayed atPT15M.
At the Worker's */5 cadence a 15-minute window holds 3 rows, so a threshold
of 3 fires unless every one of them arrives and is queryable. wrangler.toml
records that App Insights availability rows "land 1-3 minutes late and
sometimes later" — which is why the 30-minute window existed. The finding was
not just left open; it was inverted. The shape it replaced (PT15M,
threshold 2) tolerated one miss. The shape that replaced it tolerated none, on
a Sev 1 rule re-evaluating every 5 minutes.
Caught while preparing to arm availability_probe_alert_enabled for the first
time, so it never fired. window_duration is now PT30M. Both halves now
carry the pairing rule in the place someone would edit them —
observability.tf beside the window, wrangler.toml beside the cron — and
the review entry records the correction rather than quietly flipping back to
open.
The transferable part: every artefact that described the fix was updated, and the one line that implemented it was not. Nothing compares a Terraform attribute to the prose around it, so four consistent descriptions read as four confirmations.
- The probe's secret held the wrong value, and the diagnostic built for
exactly this found it in one look (T-519, T-746).
availabilityResultswas empty for the whole table — not just foredge-api-health— since the Worker was deployed on 2026-08-31.wrangler tailnamed the cause on the first*/5invocation it saw:
"*/5 * * * *" @ 8/31/2026, 8:15:28 PM - Exception Thrown
Error: APPLICATIONINSIGHTS_CONNECTION_STRING must carry InstrumentationKey and IngestionEndpoint
at parseConnectionString (worker.js:15:11)
The cron, the deploy and the schedule were all healthy; parseConnectionString
threw before a byte was sent, every five minutes — silent from Azure's
side, which is the only side anyone was watching.
It was not silent in Cloudflare, and that distinction is the whole value of
T-746. runProbe deliberately does not retry or trap the ingestion POST, so a
failure yields no telemetry rather than a failure row, and a dead cron, a dead
Worker and a dead ingestion path all render as the same empty table. What
separates them is the .catch in scheduled that logs and rethrows — the
[availability-probe] run failed: line quoted above is that console.error.
It paid for itself here.
The instruction is what caused it, so that is what changed.
wrangler.toml said "Azure portal → the Application Insights resource →
Connection String" — and the portal shows Instrumentation Key directly above
Connection String, only one of which parses. It now carries a piped
one-liner that reads the value from ARM and feeds it straight to
wrangler secret put, so it never reaches a screen, a clipboard or shell
history and cannot be the wrong field. The comment also said
appi-site-prod-cus; the resource is appi-site-prod-cus-01.
Two eliminated hypotheses are recorded so nobody re-runs them: the
workspace is not over quota (dataIngestionStatus: RespectQuota against the
0.25 GB cap), and the cross-subscription read works — rows: [[0]] is a valid
result set, not a denial. log-plat-prod-cus-01 is in the Management
subscription, so az against rg-mgmt-plat-prod-cus needs --subscription
or it answers ResourceGroupNotFound.
TODO.md's verification command was replaced for the same reason the
instruction was: (… | ConvertFrom-Json).tables[0].rows yields $null
silently when az fails, and -o table renders nothing for this nested shape
at any row count. Both make a broken tool and an empty table print the same
blank line. It is now raw -o json over summarize count(), which always
returns exactly one row.
- T-726's owner actions are complete, and the merge question is answered
(2026-09-01). The App is created,
MANIFEST_APP_IDandMANIFEST_APP_PRIVATE_KEYare stored, and auto-merge is enabled. The open question — whether the ruleset would let the manifest pull request merge unattended — resolves to yes, on evidence rather than assumption: every pull request merged on 2026-08-31 and 09-01 (#301-#308) went in with noAPPROVEDreview, #308 carrying twoCOMMENTEDones, and with no bypass actors each merge had to satisfy thepull_requestrule on its own terms. Sorequired_approving_review_countis 0.
Still unproven: the App path itself, which runs only when the published set moves. Manifest run 11 reported "No change to the published set" and skipped the mint entirely. The first article published is the test.
- "Wait for a success row" was the wrong gate for arming the reachability
alert (T-519). The runbook written earlier the same night said to arm
availability_probe_alert_enabledonce asuccess == 1row was visible. That is necessary and not sufficient, and the gap is arithmetic rather than judgement.
The rule fires when successes in the trailing PT30M window are
LessThan 3, and the Worker writes one row per 5 minutes, so a full window
holds 6. When the probe's secret was fixed at 2026-09-01 01:35 UTC the table
went from 0 rows to 2 within ten minutes — satisfying the old gate exactly.
Arming there would have counted 2 successes, which is less than 3, and paged
Sev 1 on a probe that was working perfectly, for the same reason arming with
zero rows would.
The gate is now the count itself: 6 or more, which is 30 minutes of healthy probing and means the window is full and the designed tolerance — 3 missing out of 6 — applies from the first evaluation.
Third time in one night that this alert's window and threshold had to be
reasoned about together (after T-745's half-applied PT15M, and the pairing
warnings added beside both halves). The window is the parameter everything
else in this rule is measured against, and every mistake so far has come from
reasoning about one of the pair without the other.
- Every Terraform run printed the same deprecation twice for an output nothing
consumed (2026-09-01). Once for the refresh and once for the plan, both
naming the same line — two complete, identical blocks in the run output, which
is where the count comes from. Distinct from the "Value for undeclared
variable" warnings on those runs, which come from three stale TFC workspace
values and are an owner-side deletion; that distinction is the reason the
count is stated at all.
cloudflare_planreaddata.cloudflare_zone.current.plan, deprecated in Cloudflare provider v5:
Please use the
/zones/{zone_id}/subscriptionAPI to update a zone's plan. Changing this value will create/cancel associated subscriptions.
Removed rather than migrated, because the replacement the warning names is
not usable here. In v5 the non-deprecated path is
cloudflare_zone_subscription — a resource, not a data source — so reading
the plan through it would put Terraform in charge of the subscription, with
exactly the create/cancel behaviour the warning describes. Trading a cosmetic
warning for a plan Terraform can cancel is not a trade worth making for a
value nothing reads.
The output existed since 2026-08-20 to settle an architecture argument about
the plan tier, and that argument is settled. The tier still matters — ADR
0024, wiki/Availability-Probe.md, infra/observability.tf and
infra/variables.tf all reason about "this Cloudflare plan" — but it is a
static fact that changes only when a human changes it, so
wiki/Required-Inputs.md now records where to read it instead of Terraform
re-reading it on every plan. The cloudflare_zone data source went with it;
the output was its only consumer, and var.cloudflare_zone_id is still used
by infra/frontend.tf.
- The frontend deploy's pre-render guard could not pass, and blocked every
deploy for two days (2026-09-01).
deploy-azure-frontend.ymlgreps the built/aboutpage to prove pre-rendering produced a page rather than a shell. Its pattern was the literal<div id="root"><[^/], written when the mount point carried no attributes. #296 (T-714) then stampeddata-prerendered-routeonto that same div, so the literal stopped existing anywhere in the output:
dist contains 121 HTML documents
##[error]dist/about/index.html has an empty mount point — it is a shell, not a pre-rendered page.
The page was fine — 21 KB of real markup. The check could no longer pass on any input, however correct.
It went unnoticed because it fails closed. A broken guard here blocks a deploy rather than admitting bad output, so nothing shipped wrong and nothing looked wrong; it simply waited until someone deployed. Two runs died on it before the cause was read. That is the good failure direction and it is still a two-day outage of the deploy path, which is worth separating: failing closed bought correctness, not visibility.
Fixed structurally, because the pattern was a truth in two places. A bash
literal in a workflow described a shape prerender.mjs emits, with nothing
holding them together. The pattern is now MOUNT_POINT_PATTERN, exported from
prerender.mjs; the workflow greps that exact text; and prerender.test.js
reads the pattern back out of the workflow file and asserts it against real
injectIntoTemplate output — matching a rendered page, matching one whose seed
contains a bracket, and rejecting both a shell and the untouched vite template.
Editing either side alone now fails the frontend test job minutes after a
push, instead of a deploy whenever the next one happens to be. Mutation
tested: restoring the old literal fails the assertion and names both patterns.
escapeAttr also escapes > now. A raw > inside a quoted attribute is legal
HTML so nothing rendered wrong, but it makes the value unscannable by the
[^>]* the guard relies on — a seed containing > would have made the check
read the tag as ending early. The test's hand-written unescapeAttr inverse
learned it too, which is exactly the drift that function exists to catch.
- The alert rules re-notified every five minutes, because a scheduled query
rule is stateless by default.
alert-app-exceptions-prod-cusfired at 23:06 on 2026-08-25 — the first firing of any rule on this platform — and then kept firing. That was not the rule detecting anything new.autoMitigatedefaults to false onazurerm_monitor_scheduled_query_rules_alert_v2, and a stateless log rule notifies on every evaluation whose condition is met, so atPT5MAzure sends a fresh Sev1 mail every five to ten minutes for as long as the condition holds. The window makes it worse rather than better:PT15Mevaluated everyPT5Mmeans the same burst of exceptions is counted by three consecutive evaluations, so the mail continues for a full fifteen minutes after the last exception was thrown.alert-func-latencywas the worst of the three at aPT30Mwindow — half an hour of mail about latency that had already recovered.
auto_mitigation_enabled = true on alert-func-http5xx, alert-func-latency
and alert-app-exceptions. Each now fires once, stays fired while the
condition holds, and sends one Resolved mail after the condition has been
clear for three evaluation periods.
Detection is unchanged and nothing is suppressed — same evaluation frequency, same query, same threshold, same severity. That is the entire reason this change was made on one night's evidence rather than waiting for the week of firing data ADR 0022 asks for: it is the only available remedy that does not trade coverage for quiet. Every other lever — filtering the query, requiring two failing periods, raising the threshold, lowering the severity from Sev1 — makes the rule detect less, and none of them should be pulled before the diagnostic query now in Alerting and support says what is actually throwing. Those levers are documented and ordered there; the thresholds themselves remain the first estimates ADR 0022 declared them to be.
mute_actions_after_alert_duration is mutually exclusive with auto-mitigation
and stays where it already was, on alert-logs-capacity alone: its condition
cannot clear before the 08:00 UTC cap reset, so there is nothing for
auto-resolution to resolve. The two metric alerts needed no change — metric
alerts are stateful by default. Recorded as decision 6 in
ADR 0022.
- The SCM lock is armed, and the per-run window is proven under
Deny(T-520 closed, 2026-08-25).functions_scm_lock_enabledwas set totrueon thehcw-azureworkspace and applied;az functionapp config access-restriction shownow reportsscmIpSecurityRestrictionsDefaultAction: Denywith a singleDeny allrule. The Kudu endpoint no longer answers the internet.
Deploy run 32902534458 is the evidence that matters, because arming a lock is only half a claim until something has to get through it:
scm default action before the window: Deny
Will use Kudu https://<scmsite>/api/publish to deploy since Flex consumption
plan is detected.
Successfully deployed web package to Function App.
functions registered after sync: 109
scm window closed — default action 'Deny', no temporary rules left
Every part of the design did what it was written to do. The open step read
the baseline as Deny rather than assuming Allow, which is why the same
steps worked unchanged before and after the flip. The deploy published
through Kudu with the standing default denying everyone else. The close step
removed the rule, confirmed the posture it found was the posture it left, and
confirmed no ci-deploy-scm-* rule survived — the assertion that keeps a
window which silently failed to close from passing as a green deploy.
Sequencing mattered and is recorded because it is the part that would bite on
a repeat: the window shipped in #220 and was observed working under Allow
(run 32894382986) before the variable was flipped. The first deploy after
a premature flip is the one that cannot get in to fix itself.
This closes the reachability half of the exposure. The credential half was already closed — basic authentication is off on both SCM and FTP — so the endpoint now requires an Entra token and an allow-listed source.
- Production deploys could not authenticate at all, and the cause was a
workflow edit rather than an identity problem.
de99aa0putdeploy-functions.ymlbehindenvironment: productionto gate production deploys. That is correct in itself and had a consequence nothing accounted for: declaring an environment changes the OIDC subject GitHub composes. It becomesrepo:<org>/<repo>:environment:<name>rather thanrepo:<org>/<repo>:ref:<ref>, so the branch credential cannot match a job that names an environment — the ref form is simply not what is presented.infra/oidc.tftrustedref:refs/heads/mainandenvironment:data-migrationand nothing else, so every production deploy failed atazure/login:
AADSTS700213: No matching federated identity record found for presented
assertion subject 'repo:HybridCloudWorks@312844660/
HCW-HybridCloudWorks@1268997852:environment:production'
Observed on run 32892582041, the first dispatch after that merge. It had been broken since 2026-08-24 and stayed invisible because no deploy ran in between — the failure is silent until someone deploys, and by then it reads as a permissions or tenant problem rather than as the consequence of a workflow edit.
Fixed by trusting environment:production in both the name and
immutable-ID forms, matching the existing pair for every other subject: six
federated credentials against a cap of 20. The branch pair is not
redundant now and must not be swept up in a future data-migration cleanup —
heal-computed-properties.yml and publish-content-manifest.yml declare no
environment, so they still present the ref subject. The rule is per-workflow,
not per-repository.
A guard now fails the build instead of a deploy. scripts/oidc-subjects.test.mjs
cross-references every workflow that uses azure/login against the subjects
infra/oidc.tf declares, and fails naming the missing credential and the
error it would have produced. No linter or terraform validate could have
caught this — both files were individually valid, and only the relationship
between them was wrong. The guard was verified by reverting the fix and
confirming it fails, rather than assumed to work because it passes. It also
asserts both subject forms exist for each environment, since one without the
other is half a credential that fails on whichever form the token carries.
-
SCM reachability is closed by a per-run deploy window (T-520, #220).
scm_ip_restriction_default_actionwas the literal"Allow"while the front-end origin was locked toDeny. Verified against the live app 2026-08-25: SCM defaultAllow, main siteDenywith 17 rules,scmIpSecurityRestrictionsUseMainfalse. Changing the literal was never the fix — the Flex Consumption deploy publishes through Kudu and GitHub-hosted runners have no stable egress IPs, so a standingDenybreaks every deploy.deploy-functions.ymlnow opens a window before the deploy and unwinds it before the storage window closes. Three things differ from the storage window and each is deliberate: no default-action flip is needed, because App Service honours SCM IP rules normally where the storage firewall ignores them for same-region callers, so the standing posture is never widened; the baseline is read rather than assumed and the close step asserts the posture it found is the posture it left, which is correct both before and after arming, where asserting "Denyis back" would fail every deploy until the flip; and the open step fails ifscmIpSecurityRestrictionsUseMainis not false, since SCM would then inherit the Cloudflare-only origin lock and the runner would be refused with the window apparently open. The close step also asserts noci-deploy-scm-*rule survives — a window that silently failed to close is worse than one that never opened, because the deploy stays green while the endpoint stays admitted.functions_scm_lock_enableddefaults tofalse, so the apply is a no-op on behaviour; arming it is a workspace edit and the window must be observed working on a real deploy first. The credential half was already closed: basic authentication is off on both SCM and FTP. -
iac-validatereports on every pull request, so it can become required (T-523, #220). Both jobs are meant to be required contexts on themainruleset, and adding them while the workflow stayed path-filtered toinfra/**would have deadlocked the repository: GitHub does not auto-satisfy a required context whose workflow was filtered out, so every pull request not touchinginfra/would have held at "Expected — waiting for status to be reported" indefinitely. The trigger therefore changes before the ruleset does.pull_requestloses itspaths:filter and the filtering moves inside each job — check out, diff against the base commit, skip the expensive steps wheninfra/did not change — while the job still completes and posts its context.pushkeeps its path filter, because required contexts are a pull request concern. Two details fail silently if got wrong and are recorded in the workflow:fetch-depth: 0, since a shallow clone does not contain the base commit; and the detect step overriding the terraform job'sworking-directory: infra, since run frominfra/the^infra/prefix match never matches. Skipping is at step level rather than job level because whether a skipped context satisfies a required check is behaviour worth not depending on. The ruleset half remains owner-gated. -
The unlabelled form controls are associated, and the rule that finds them now runs (A-001, #220). The rule could not run at all, so the twenty violations recorded against it had never been observed: it crashed with
(0 , _minimatch.default) is not a functionon the first file containing a label, and an ESLint rule crash aborts the entire run. The cause was this repository's own supply-chain override rather than the ESLint version.eslint-plugin-jsx-a11ydeclaresminimatch: ^3.1.2and imports it as a default export, whilepackage.jsonoverrode minimatch to^10tree-wide to carry the brace-expansion advisory fix — and minimatch v10 exports no default. The override that closed one supply-chain hole had silently disabled an accessibility rule. Repaired with a scoped override giving the plugin minimatch^3.1.2(resolves 3.1.5, past the 3.0.5 ReDoS fix) and brace-expansion^1.1.12(resolves 1.1.18, past the advisory), leaving the rest of the tree on 10.2.5; the lockfile change is 38 lines, all additions. With the rule running, the twenty findings proved to be two different things. Seventeen are genuine — a<label>that is a sibling of its control with no association, so a screen-reader user gets no field name — fixed by pairinghtmlFor/idacrossArchitectureReviewBoard(2, keyed per hotspot id since they render in a map),FrameworkReviewBoard(4),MetadataTab(4) andSpeakingEventsPage(7). The other three are not defects: inListenAndLearnPagethe label already wraps its control, which is an association, and the rule reported them only because it cannot see a custom<Input>as a control. Those are fixed by configuringcontrolComponents, because rewriting working markup to satisfy a misconfigured linter would have been the wrong repair. The rule is nowerrorrather thanoff, so neither half can regress. -
REVIEW.mdPart 4 is restored as the required-inputs inventory (T-521, #220).59e471bcutREVIEW.mdfrom 1,011 lines to 58 and moved the narrative to the Wiki, leaving twelve references toPART 4 — REQUIRED INPUTSacross eight files pointing at a section that no longer existed. Two of them were live procedure with nowhere to land:CONTRIBUTINGtells a contributor to record new required inputs there, and the Deployment Runbook tells an operator to move an entry fromSETtoVERIFIEDafter an apply. The defect was the absent section rather than the references, so the section is restored and all twelve pointers are untouched. Only the inventory comes back — the original §4.0 naming and placement rules are nowwiki/Variables-And-Secrets.md's job, and restoring them verbatim would have recreated exactly the duplication the Wiki move ended; each file's intro now names the other. The statuses do not share one confidence level and the section says so rather than presenting a uniform claim: GitHub variables (23), secrets (1) and environments (3) were enumerated live on 2026-08-25, so their presence is observed; Key Vault was not readable,az keyvault secret listreturningForbiddenByRbacbecause the caller holds no data-plane role, which is itself the correct posture, so §4.6 lists the nineteen secretsinfra/main.tfreferences — establishing each name and consumer but not its presence; and the HCP Terraform workspace was likewise not read, so §4.1's statuses are labelled as carried forward from 2026-08-20. The Terraform tables are generated from the configuration rather than transcribed: 8 of 58 variables have no default and must be set in the workspace, and the seven posture switches are listed with what arming each one does, since those are the entries most likely to be misread as settings. The same live pass is what confirms T-525's three scratch variables are still set with no reader, and that thedata-migrationenvironment outlives the workflow deleted in59e471b. -
The anonymous feed endpoint is bounded in articles, not just in feeds (T-319).
GET /api/public/feedcapped how manyrss_cachedocuments it returned but not how many items each one carried, and one document is one whole feed — so a hundred bounded documents could still be an unbounded response. Each surviving document is now trimmed to its newest twenty items bypubDate, withitemCountrewritten to match so the count cannot describe items that are not in the response. Undated items sort last and are dropped first (Date.parse('')is NaN, and a missing date is not "now", so one malformed item cannot evict a dated article); an all-undated feed keeps its stored order, and a document whoseitemsis absent or not an array is passed through untouched rather than turned into a plausible-looking empty feed. The read ceiling is a second copy of the ingest writer'sMAX_CACHE_ITEMS_PER_FEEDbecausepublic-reads.jsdeliberately has no imports;public-reads.test.jsasserts the two agree so they cannot drift. - The ingest cap keeps the newest items rather than the first (T-319).
buildCacheItemssliced the parsed feed in arrival order. Feed order is conventionally newest-first but nothing enforces it, and both readers of the array sort bypubDate—buildHomepageFeedItemsanduseNewsData.js— so a feed publishing oldest-first cached its archive and never showed its recent articles, with the cache looking full throughout. The sort now happens before the slice, andprocessSingleFeedno longer pre-slices in feed order, which would have decided the selection beforebuildCacheItemscould. Drafting is unchanged: it still walks the first ten items of the parsed feed. -
PATCH /api/cms/{ai-providers|mcp-servers}/{id}no longer persists thehasOauthTokenread artefact.stripOAuthTokensynthesises the flag on every read in place of the write-only token, so a form PATCHing a field it read back sent the boolean with it — andputConfigalready dropped it for exactly that reason whilepatchConfigdid not. Reads recompute the flag, so it shadowed nothing; it was a stale copy of a secret's state written next to the secret, which a later revoke would not have cleared. A PATCH body left with no updatable field afteridandhasOauthTokenare dropped is now a400rather than a write that touches onlyupdatedAtand reports success. -
CORS_ALLOWED_ORIGINScould never have worked — the name collides with a platform-injected variable (T-513). App Service injects read-only CORS environment variables derived fromsiteConfig.cors.allowedOrigins, which is astring[]. Ours is unset, so the worker received the serialisation of an empty array — the literal two characters[]— in place of whatever was written to the app setting.parseExtraOriginssplit that on comma and produced one "origin" called[], which matches nothing.
Renamed to EXTRA_ALLOWED_ORIGINS. Nothing here may be called CORS_* or
WEBSITE_* again.
Three independent writers proved it, and the last one was conclusive.
Terraform via azurerm, Terraform via the azapi strip, and a plain
az functionapp config appsettings set each put the correct value in ARM; all
three times the worker reported []. The final experiment carried three
keys in one CLI write — RUNTIME_CONFIG_GENERATION,
RUNTIME_CONFIG_WRITER and the origins. The worker reported the first two
verbatim and the third as []. Same write, same instant, same process, two
distinct HostInstanceIds. Only the name differed.
That sequence also exonerated Terraform and the azapi rewrite, which had
been the prime suspect on the reasonable grounds that they were the newest
thing rewriting the whole settings collection. They were innocent, and the
generation/writer sentinel is what showed it: the workers reported
writer=azapi-strip and later writer=cli with the current generation
every time, so they were never stale and never missed a write. Without the
writer dimension the conclusion would have been "stale worker" and the search
would have continued in the wrong place.
- Telemetry had been dead since 01:33Z and request telemetry had never worked
at all (T-514). Two faults wearing one coat, both in
host.json.
log-plat-prod-cus-01 caps ingestion at 0.25 GB/day and read
OverQuota. What filled it was not the application: Azure.Core logged
39.3 MB across 76,125 messages in 24 hours — every SDK HTTP request and
response at Information, driven by the host's continuous blob-lease polling —
with Azure.Identity adding 4.4 MB. Application logs were collateral. Both
categories are now Warning; fixing the noise beats paying for it.
Separately, Host.Results was set to Error. Request telemetry is emitted at
Information, so that one line emptied the AppRequests table permanently —
it had zero rows, ever. That is the table that answers "did the timer
fire", so Migration-Plan §7's scheduled-job gate was unobservable by
construction. Restored to Information. Host.Aggregator was left on
Trace, the most verbose level available, for a diagnosis nobody recorded;
now Warning.
Two conclusions this reverses. The [cors] diagnostic from the previous
entry was written correctly and discarded at ingestion, and the [telegram]
control that appeared to prove "no worker logs reach App Insights" was a
false negative — worker logging works.
And a tooling trap. az monitor app-insights query --app <appId>
returned zero rows for every query, including with no time filter, while the
workspace held 138,220 traces. The component is workspace-based with the
workspace in another subscription, and the proxy returns empty rather than
erroring. Query the workspace and the AppTraces / AppRequests tables
directly.
- The inbound Telegram bot is ported (T-512), not retired. Migration-Plan §6
step 6 said to rewrite
getTelegramWebhookUrl()and re-runsetWebhook; there was nothing to point a webhook at, because no receiver had been ported —notify.jsonly sends, and no route accepted a Telegram update (checked against the deployed route table, not just the source). Unlike Cloud Tools (T-410) or Listen & Learn (T-411) it was never recorded as a deliberate demotion, so nobody had decided it. The owner chose to keep the bot.
POST /api/telegram/webhook now serves the eleven commands and the free-form
Q&A from Site-Main's telegram-bot.js + telegramWebhook. Two things
changed in the port, both forced by the platform:
- Long commands enqueue instead of running inline. Upstream answered
Telegram with 200 immediately and kept working afterwards, which Cloud
Functions tolerates and Azure does not — an invocation ends at the
response, so
/forgeand/inspectwould have been dropped silently about as often as they ran. Those two and/rssnow enqueue the platform job that already exists for each (forge-article,batch-inspect,fetch-rss-feeds, T-322) and reply with the job id. - The route is anonymous, and that is the only option. Telegram cannot
send a bearer token, so
requireRolehas nothing to check. It is guarded by two independent checks instead: theX-Telegram-Bot-Api-Secret-Tokenheader compared in constant time againstsha256(TELEGRAM_BOT_TOKEN)— the secret is derived, not stored, so there is only one thing to rotate — and the sending chat id againstTELEGRAM_CHAT_ID. The first proves Telegram sent it; the second proves the owner did, because anyone who finds a bot can message it. An unauthorized chat gets no reply at all, so the bot cannot be used to confirm it exists.telegram/webhookis inPUBLIC_ROUTESwith that reasoning recorded next to it.
It always answers 200 once the secret validates: Telegram retries non-2xx,
so a 500 on a bad command turns one broken message into a retry storm that
re-runs the command every few seconds. 32 tests, weighted on the two
authorization checks, since a mistake in either makes this an
unauthenticated remote control for the platform. scripts/cutover/04-telegram-webhook.ps1
re-registers the webhook and preflights the receiver first — a webhook aimed
at a 404 makes Telegram back off, so the bot stays broken after the real fix.
- Eight CMS functions never started — seven route templates were each
declared two or three times (T-510). The Azure Functions host keys its
route table on the route template alone, not template + method, so two
functions declaring the same
routewith differentmethodsconflict: the host starts one and refuses the other with "is in error: The route specified conflicts with the route defined by function X". The losing verb answers GET/PATCH/PUTwere lost acrosscms/certifications,cms/certifications/{id},cms/recordings,cms/social-posts,cms/settings,cms/config/{collection}/{id}andcms/keyword-config/{collection}/{id}— list, edit and save for most of the admin UI, all of which the frontend calls. Confirmed live before the fix:POST /api/cms/certifications401,GET /api/cms/certifications404.
Present since the 84-function deploy (App Insights, 21:39:15Z 2026-08-21) and
invisible because the admin surface is not deployed yet, so nothing had ever
called them. Each pair is now one registration via the new
httpRouteByMethod, which declares every method on one template and fans out
on request.method; each verb keeps the guard it already had. 79 HTTP
registrations become 71 and the deploy total 104 → 96, all serving.
route-inventory.test.js could not have caught it: its mock is
http: (name, options) => httpRegistrations.set(name, options), a Map keyed
by function name, so both halves of a conflict register and pass properties
1–3 — every one of them was individually correct. New property 4 asserts
no two registrations share a route template (parameter names collapsed, case
folded, matching how a router compares them) and that every method a merged
registration declares has a handler behind it. Verified by injecting a
conflict and watching it fail.
- The keyless
AzureWebJobsStorageis written by Terraform, not by the deploy — the attribution ininfra/main.tfanddeploy-functions.ymlwas wrong, and the Azure activity log is the only place the two are distinguishable: the 20:02Z deploy deleted the setting, Terraform's 20:31Z apply was the onlysites/configwrite after it, and the setting was back.azurerm_function_app_flex_consumptionre-injects it on every apply whateverstorage_authentication_typesays, without surfacing it in plan (azurerm#29149, open on the pinned 5.1.0). Nothing in this repository can stop the write, so it is now stripped inside the same apply that creates it: anazapi_resource_actionreads the settings azurerm has just written and anazapi_update_resourcewrites them back without that key. The setting never survives the run, so there is no post-apply step, no scheduled job and nothing to remember.deploy-functions.ymlasserts it is absent and fails rather than deleting it — a repair there would hide a regression in the strip, which is how this stayed a recurring incident instead of becoming a bug: every occurrence was quietly cleaned up by the next deploy.
Not used: "AzureWebJobsStorage" = "", the workaround the issue is best
known for — it stopped working in early May 2026, per three reporters — nor
rewriting the function app as a raw azapi_resource, which trades a
well-understood resource for a hand-written ARM body to dodge one bad key.
Both misattributing comments corrected; T-511 tracks the upstream close.
- The public content list failed the moment
PUBLIC_LIST_SQL_ORDERwent live — Cosmos: "The index path corresponding to the specified order-by item is excluded". Computed properties are not covered by the/*wildcard (the comment inpublic-reads.jssaid they were);/cp_sortDate/?is now an explicit included path oncontentandblogs, applied live through ARM with the property preserved and carried in the generated spec so Terraform agrees. 40 minutes of 500s on the list endpoint, 2026-08-21. - New functions were not registered after the deploy — SyncTriggers
failed on a keyless
AzureWebJobsStorageconnection string the deploy leaves behind (the same cause as the 2026-08-20 every-route-404). 83 deployed, 80 registered;enqueueJob,getJoband the job worker did not exist until the setting was deleted and triggers re-synced by hand.deploy-functions.ymlnow does both after every deploy and fails if the registered count is zero. cp_sortDateis live oncontentandblogs(healer run 32448029469, 2026-08-21, first successful run on this estate) and the healer workflow can now be dispatched withmode=inspectto check the precondition forPUBLIC_LIST_SQL_ORDER=1, whichinfra/main.tfnow sets — the public content list asks Cosmos for the newest N rather than an arbitrary N. T-206's final step. The custom role the healer needs is created once by the owner frominfra/roles/cosmos-container-writer.jsonand consumed by data source; the Terraform identity deliberately cannot define roles (#137).- The healer can now actually heal.
heal-computed-properties.ymlhad never succeeded on this estate:cp_sortDatewas absent from bothcontentandblogson 2026-08-21 with 1,142 documents incontent. SettingcomputedPropertiesis a control-plane operation, and the SDK'scontainer.replace()sends it to the data plane, which Cosmos refuses with an AAD token regardless of roles.--applynow does an ARM PUT on the container resource (polling the async operation and re-reading to confirm), authorized by a new custom role — SQL container read + write on the one account, nothing else; not "Cosmos DB Operator", which isdatabaseAccounts/*minus keys.buildArmBody()strips the read-only keys and is unit-tested. New outputcosmos_resource_group→ variableCOSMOS_RESOURCE_GROUP(T-508). deploy-functions.yml's storage window now survives a same-region runner (T-509): the same default-action Allow/Deny bracketmigrate-data.ymlgained in #134, with the Deny restored first and verified.heal-computed-properties.ymlstill readsecrets.COSMOS_ENDPOINTafter the value moved to a repository variable and the secret was deleted (2026-08-20); its next run failed with "COSMOS_ENDPOINT is not set". Nowvars.COSMOS_ENDPOINT. The #128 changelog entry said both consuming workflows had been switched; onlymigrate-data.ymlhad.preflight-firestore-inventory.mjsreferencedFIRESTORE_PROJECT_IDwithout importing it. Introduced when the Firestore connection moved intoconnectFirestore();node --checkand the 65 tests all passed because an undefined identifier is a runtime error on a line no test reaches. The firstmode=preflightdispatch frommain(run 32435060952, 2026-08-21) found it — after proving the GCP Workload Identity Federation chain end to end, which is the part that could not be tested locally. Fixed, andscripts/now has an ESLint config withno-undefas an error, run by thescripts (migration)CI job; a sweep of every script found no other instance.migrate-data.ymlcarriedCOSMOS_KEYandCOSMOS_DATABASE: hybridcloudworks. Key auth is disabled on the account and the database ishcw, so every import would have failed — with an error naming neither. Both removed; the workflow also lackedid-token: write, so it had no OIDC path to either cloud.- Eleven
movedblocks removed frominfra/main.tf. Verified no-ops: the centralus rebuild recreated every container from the spec while all were empty, andterraform state listshows only thefor_eachform. A three-line note records that the partition-key change happened through the rebuild. - Stale counts and comments.
main.tf's partition-key comment (67 on/idand five exceptions, not 62 and four); thecosmos_database_namecomment (the scripts default tohcw, nothybridcloudworks);cosmos-client.js(67 of 72, not 66 of 71); and the storage lifecycle rule forarticles/is now documented as inert — Azure matches<container>/<blob>and noarticlescontainer exists. set-github-variables.ps1and REVIEW §4.2 omittedFUNCTION_APP_NAME, which is set and consumed bydeploy-functions.yml.-
Azure/functions-action@v2does not exist. Found while resolving tags to SHAs: that action's newest tag isv1.5.7and its release branch isreleases/v1, sodeploy-functions.ymlcarried a reference that resolves to nothing and would have failed with "Unable to resolve action" the first time the workflow was enabled. Pinned tov1.5.7. The workflow is stillif: false, which is why no run had ever surfaced it. -
frontend/.env.examplerewritten against the real environment surface (T-403).VITE_ENTRA_API_SCOPEwas required and undocumented — without it every token is acquired for no scope, so sign-in succeeds and every API call fails on audience. The file meanwhile documentedVITE_OWNER_ADMIN_EMAIL/_UID, which nothing reads, and carried Firebase secret-set instructions for decommissioned tooling. Rewritten against the actualimport.meta.envreferences. -
queryDocsdoes not discard the continuation token (T-311) — recorded because the opposite was asserted in review, and a wrong finding costs more than none.fetchAll()consumes the token rather than dropping it: the SDK'stoArrayImplementationloopswhile (hasMoreResults()), accumulating every page. No change was made because none was needed. -
iac-validate.ymlTrivy job unresolvable action pin — the gate shipped in PR #103 referencingaquasecurity/trivy-action@0.28.0, a tag that no longer resolves: Aqua's 2026-03-19 security incident (trivy discussions #10425) saw trivy-action git tags re-pointed to malicious commits, and the v0.69.4 binary release was itself malicious. The job now installs the Trivy binary pinned to v0.69.3 — the latest release the advisory names safe — from the project's own release artifacts, verified against the release checksum manifest, and no longer uses the marketplace action at all. (PR #104) -
The frontend CI gate now runs the whole test suite.
test:adminwas a hand-curated file list — every new test file had to be added by hand, and eight known-stale failures elsewhere were simply never run. The eight were stale expectations, not application defects, and are fixed: the route contract now asserts the real pages behind/gcp,/terraform,/github,/finops, the three/toolsroutes and the two news routes (mocked, as the suite already did for other providers); and the PublishedPage tests drive the publish flow that actually exists — a pre-publish checklist modal whose "Publish Now" is what publishes — with the checklist itself now unit-tested.test:adminis plainvitest run; the one legitimately unrunnable file (firestore.rules.test.js, which needs the retired Firestore emulator setup) is excluded in vitest.config.js with the reason recorded. Default run: 15 files, 115 tests. (TODO.md T-320) - One anonymous list request could eat four seconds of the database's entire
budget. The public content list ran
SELECT TOP 1000 *with no WHERE — an arbitrary 1000 documents of a ~1k-document container (so published articles could vanish from listings non-deterministically, made intermittent by the 300 s cache), each transferred whole at ~20 KB including article bodies no list consumer renders. The public filter now runs in SQL, so the window counts published documents of the requested type/provider; and the projection is an audited explicit field list — the union of what the public list consumers actually read, pinned by a test naming the consumer behind each field. Of nine heavy body fields exactly one has a list reader (explanation, a Coder Corner excerpt fallback); the other eight stay out, which is where the RU win lives. The in-memory sort and the ORDER BY avoidance stay until a materialized sort field plus composite index can be deployed. (TODO.md T-206, steps 1–2) - The API contract can no longer lie about what exists. It documented
seventeen RPCs the admin UI invokes that were never registered — every call a
live 404, invisible because nothing compared the document to the code. The
contract now carries an explicit
rpc.notImplementedblock (all seventeen, blocked on provider credentials), and a test holds the whole document to account: invoked = implemented + notImplemented exactly; every implemented entry resolves to a registered route with the methods it advertises; registered method+route pairs and contract claims form a full bijection. Making the bijection true surfaced more drift, now fixed:getLabJobwas implemented but missing from the invoked list, the Labs agent API had no contract entry at all, six registered admin/public routes were undocumented, and theCRUDshorthand entries now enumerate their real routes — recording honestly that social-posts has no PATCH and recordings no DELETE. (TODO.md T-207) - Public news pages showed no curated imagery. #63 moved the cached-image
lookup off an anonymous Firestore read onto an editor-gated
cms/*endpoint, reached through a token acquisition that throws outright without a signed-in account. The hook runs on the public/{provider}/newsroute, so for every anonymous visitor the lookup failed and the grid rendered nothing where cached images used to appear. Reading a cached image is now anonymous (GET public/curated-image/{articleId}, returning only the URL — never the document, which carries an internal blob path and prompt metadata), while generating a missing one stays behind the admin gate and is no longer attempted without theeditorrole that the server requires — not merely when nobody is signed in, since a signed-in viewer would have collected a 403 per article. That also keeps MSAL off the critical path of a public page. Archived images are withheld, so retiring an image in the gallery now keeps it off the public site, and a cache miss is cached for a minute rather than an hour so a freshly generated image is not hidden behind its own absence. (TODO.md T-210) - The anonymous submission limit of five could be turned into two hundred.
The quota read the counter, compared it, and wrote it back as three separate
operations, so simultaneous requests all read the same value, all passed the
check, and all wrote
count: 1— accepted submissions bounded only by how many the caller sent, each landing in the review queue, and a counter left at 1 so the trick repeated every burst rather than once an hour. The accepted path is now a single conditional atomic increment: Cosmos evaluatescount < limitand applies the increment as one operation, and writes to one document serialize, so exactly five concurrent callers get through. Starting a window and rolling one over are the two things a predicate cannot express, so they go through operations that have a loser — a create that 409s and a replace that 412s — and the loser re-evaluates rather than assuming. (TODO.md T-204) - An IPv6 client had an unlimited submission budget. The quota key was the
hash of the full address, and a standard residential IPv6 allocation is a
whole
/64— 2^64 addresses, each hashing to its own counter, every one of them reading well under the limit, withsubmission_quotagrowing a document per address as a side effect. Addresses are now normalized before hashing: full address for IPv4,/64prefix for IPv6, with::expanded first so one address written three ways lands in one bucket, and::ffff:v4-mapped addresses treated as the IPv4 clients they are rather than collapsing every such client into a single shared bucket. (TODO.md T-205) - The editor could silently overwrite a colleague's save. Replacing
onSnapshotwith a twenty-second poll left behind a one-shot "this response is my own write" flag that was consumed by whatever the next tick happened to return. At millisecond latency that was reliably our own write; at twenty seconds it can be a collaborator's — and the branch then adopted their edit marker as our baseline, so the next save passed the server's optimistic-concurrency check and their work vanished with no warning to either person.saveEditorDraftnow returns theblogEditedAtit wrote and the client matches on that identity, so the flag is gone rather than merely narrowed. It also fixes an adjacent bug: a second save inside the poll window used to send the pre-save marker and conflict against the caller's own previous write. (TODO.md T-208) - Twenty seconds was long enough to lose an image reorder. The poll had no
change detection, so every idle tick re-applied the remote document over
orderedImageUrls— local state the user drags into order and that is only persisted on save — and re-rendered the whole editor while doing it. Ticks that carry a marker we have already seen now return early. A genuine remote change still replaces the order; the tests assert both directions, because an early return that goes too far is just a stale editor. (TODO.md T-209) totalreported the page size. Two public list endpoints measured it after slicing, so it always equalleditems.lengthand any paginating consumer would conclude there was exactly one page. (TODO.md T-407)- Two routes the frontend called did not exist.
recordLegacyBlogsReadandgetPlatformHealthwere registered nowhere — both 404s. The health one meant every anonymous visitor saw fourCHECKINGindicators resolve to "Health check unavailable" on the landing page; the telemetry one meant fallback-container reads went unmeasured, which is the evidence for retiring that container. Both were invisible until T-101, because until then they were pointed at the decommissioned Google host. (TODO.md T-316) - Scheduled publishing works.
scheduledPublishDatehad a complete write side and no read side: an operator scheduled a post, the server validated and stored the date, the UI confirmed it, and nothing ever published it — no error, no alert.publishScheduledContentnow runs the sameprocessPublishContentpipeline the Publish button uses, rather than a second implementation of it, clears the schedule only after a publish that actually happened, caps each tick at 25 with carry-over, and records failures under thescheduled_publish_failuresalert type the ops dashboard has counted since the migration without ever having a producer. (TODO.md T-301) - Two concurrent publishes can no longer both succeed.
patchDocgained an optionalifMatch, and the publish write is now conditioned on the ETag read at the top ofprocessPublishContent— the status gate, quality and image reports and slug were all decided from that document. A lost race is reported as skipped rather than counted as a publish that did not happen. Timer-driven publishing is what turns this from theoretical into reachable. (TODO.md T-301) - The four timers no longer share one flag. Enabling the scheduled publisher
would also have armed
cleanupTempStorage, an unimplemented TODO that deletes blobs. Each timer has its own flag;FEATURE_FLAG_SCHEDULERSis a master kill switch. The blob-GC job itself is still unwritten and still flagged off. (TODO.md T-302, flag half) - Every admin list sort worked again.
PublishedPageandEditorListPagekept the Firestore-only?.toMillis?.() || 0, which against the ISO strings Cosmos returns scores every document 0 — so every comparator returned 0, the lists rendered in raw database order while the sort controls appeared to work, and the timestamp columns showed an em dash. Onelib/dateUtils.jsnow backs all of it. The review counted seven copies of that helper; there were ten, and a source guard in the new test file found the last three — one of which only surfaced when the bundler refused a redeclaration that ESLint had passed. (TODO.md T-304) - The review board no longer blanks on a scheduled item.
BlogReviewBoardcalled.toDate()on what is now an ISO string, inside asetTimeoutand so outside the error boundary. (TODO.md T-303) - A published article can no longer 404 because a draft shares its slug.
The detail lookup ran
SELECT TOP 1with noORDER BYand applied the public filter afterwards, so it picked arbitrarily among duplicates and then rejected the winner. It now orders by_ts— a system property present on every document, so the drop-on-undefined trap does not apply — and finds the first public candidate. (TODO.md T-305) - The Labs dashboard reported agents "connected" through an outage. The
staleness clock advanced only inside the snapshot fetch's success path, so a
failing poll froze it:
now - lastSeenAtstopped growing and every agent stayed online for exactly as long as nothing was reachable. The clock is an independent interval again — it has to keep running when the fetch does not, which is the only condition under which it says anything. (TODO.md T-309) - A timed-out lab job was polled forever, and a network blip was displayed as
a failure. The console's terminal-status set omitted
timeout, which the agent does report — while the output pane in the same file had the correct four-element list, so the loop kept polling a job its own display had already called finished. Both now readTERMINAL_JOB_STATUSESfromlib/labsPolling.js. A transport error no longer writesstatus: 'failed'onto the job, which was indistinguishable from a real failure and stopped the poll permanently; it is separate state, shown as "still running — retrying", and the poll backs off from 5 s to a 60 s ceiling without ever giving up. (TODO.md T-308) - Overlapping polls could render an older document over a newer one. Both
the Labs snapshot (15 s interval) and the editor's remote-document watch
(20 s) allow a 20 s request timeout, so ticks overlap under load and responses
can land out of order. Both now skip a tick while one is in flight. In the
editor the flag is released in a
finally: its catch returns early on a missing document and on cancellation, and either path would otherwise have stopped the poll for the lifetime of the page. (TODO.md T-309) - The browser called Google Cloud, not Azure.
api.js,publicApi.jsandlegacyBlogsTelemetry.jseach resolvedVITE_GCP_FUNCTIONS_URL— a decommissioned Google Cloud Functions host — so roughly sixty call sites, including every authenticated admin request, would have been sent off-platform with an Entra bearer token attached.lib/functionsBase.jsis now the single resolver overVITE_AZURE_FUNCTIONS_URL; the deadazureConfig.jsprovider switch was deleted. The base carries the Functionsapiroute prefix and accepts either/api(same-origin) or an absolute origin (cross-origin), so deployment topology is configuration rather than code. A deploy build with no base configured now fails instead of shipping. (TODO.md T-101) - Every upload and every gallery delete would have thrown.
blob-storage.jsrequiredSTORAGE_CONNECTION_STRING, which no file ininfra/has ever produced — the code was written for shared-key auth while the infrastructure was built for managed identity. It now usesDefaultAzureCredentialagainstSTORAGE_BLOB_ENDPOINT, matchingcosmos-client.js, andgenerateSasUrlsigns with a user-delegation key instead of an account key. No key or connection string was added. (TODO.md T-104) - Uploaded images were unreachable, and the URL to them was stored anyway.
allow_nested_items_to_be_public = falseis an account-level master override, so the three containers declared public in Terraform served 409 — while uploads returned the raw blob URL for pages to persist into Cosmos. Uploads now return the media-route URL, non-public containers return none, and the Terraform containers are declaredprivate, which is what they always were. (TODO.md T-105) - Scheduled-publish dates were silently dropped —
scheduledPublishDateand the editor'sblogEditedAtwere parsed with FirestoreTimestamp-only code paths that returned0for the ISO strings the API now returns. This would have emptied the scheduling calendar and disabled external-edit-conflict detection. (#64) - Labs agents would have shown permanently offline — the staleness
calculation understood only
Timestamp.toMillis(). (#65) - Admin list projection was missing workflow fields —
scheduledPublishDate,softDeletedAt,blogEditedAtand eight others were absent from the snapshot projection that replaced whole-document Firestore reads. (#64) - MCP server connection state always read as disconnected — the write-only
oauthTokenstrip left consumers unable to detect a stored token; reads now carry ahasOauthTokenboolean while the value itself never leaves the server. (#62) - Public list endpoint under-projected — it returned a card-field subset
while consumers read
frameworkConcepts,featured,altCoverImageVariantsand more; it now returns full documents with internal fields stripped. (#61)
Security¶
- The alert checker signs in as an identity that can read alert rules and
nothing else, in both subscriptions (#1053, owner's choice 2026-10-11).
verify-alert-state.ymlran asgithub_reader, which also holds a Function App configuration write and could not read the Management subscription. So the capacity rule, the lab rules, the Management role-change rules and the action group read UNREADABLE every week. -
infra/oidc.tfaddsgithub_alert_reader, withmain-branch sign-in links in both the name and the immutable form, and grants it HCW Alert Rule Reader inrg-web-site-prod-cusandrg-mgmt-plat-prod-cus:- The role, in
infra/roles/alert-rule-reader.json, is the four reads of scheduled query rules, metric alerts, Activity Log alerts and action groups, with no data action, so the identity cannot query a log. - The owner creates it once, as for every custom role here.
principal_typeis set on both grants, because the identity is new in the same apply.
The outputs gain
alert_reader_client_id, andfederated_subjectsgains the new pair. - The workflow signs in withALERT_READER_CLIENT_IDand stays switched off until it is set. It pins no subscription (allow-no-subscriptions), because the identity's one role is at resource-group scope. It passes both subscription ids asALERT_SUBSCRIPTION_IDS, fromSUBSCRIPTION_IDand the newSUBSCRIPTION_MGMT_ID. The script uses them for any subscription the identity's list leaves out, and an entry that is not a name and a GUID stops the check. -scripts/verify-alert-state.mjsempties itsUNREADABLEtable and keeps the mechanism. Its tests now use a record of their own, and a new test pins that a refused Management read fails the run. -scripts/oidc-subjects.test.mjsmaps the new variable to the identity. - The plan checker declares the five creates. - The alerting runbook and the required-inputs table say all of this. - The role, in
-
postcss-selector-parseris pinned to 7.1.6 in the frontend, closing Dependabot alert #159 (quadratic selector parsing, CPU exhaustion, every version below 7.1.6). The only consumer,@tailwindcss/typography, still pins 6.0.10 in its newest release (0.5.20), so the fix is an npm override. The production build's CSS is byte-identical with it, so typography's output is unchanged. It is a build-time dependency that only ever parses this repository's own stylesheets. - Qlty security findings on Terraform and workflows cleared, by owner decision
(ADR 0031). Qlty listed 19 open checkov and trivy findings on
main, and the owner decided each group on 2026-09-14. - Fixed in code:
- Key Vault purge protection is on. It is one-way, and supersedes ADR 0021.
- Function host storage moves from LRS to GRS.
- The two remaining CKV_GHA_7 workflows lose their dispatch inputs.
- Storage resource logs reach Log Analytics through new diagnostic settings: queues on both accounts, and host blob writes and deletes.
- Host blob reads are not logged. Measured at about 573,000 a day, they would trip the 0.25 GB/day cap that the log alert rules depend on.
- Recorded decisions, cited by inline skips: Microsoft-managed keys, no private endpoints, no infrastructure encryption, and no Flex zone redundancy.
- Public network access stays enabled.
Disabledwould also refuse the service-endpoint firewall rules, and every data service already defaults to Deny. -
Local checkov now reports 0 failures on
infra/and the workflows, and trivy 0 oninfra/. -
The 21 remaining zizmor template-injection findings are fixed, and Qlty's TODO noise is triaged (#588).
ci.yml,codeql.yml,iac-validate.yml,repository-policy.yml,deploy-functions.ymlandpublish-content-manifest.ymlno longer expandgithub.event_name,github.run_id, the pull request base sha ormatrix.*insiderun:scripts; each reaches the script throughenv:or the runner's own$GITHUB_*variable. None was attacker-controlled, and every command runs with the same values. zizmor 1.30.1 now reports no template-injection..qlty/qlty.tomlignores ripgrep TODO/NOTE/OPTIMIZE and radarlint-iacterraform:S1135ininfra/, keeping FIXME, HACK and BUG; the triage record's rule snippet is corrected, because a rule key containing a colon only matches with its plugin prefix. Three files Qlty's prettier check flagged are formatted. -
Every security scanner finding on
mainis triaged, and most are resolved (#567, closes #562).docs/security/scanner-triage.mdhas one row per finding from a local Qlty run: 113 across zizmor, checkov, trivy, bandit, gitleaks and radarlint-iac. Each row gives the verdict and its evidence. - Fixed, 41. Every
actions/checkoutsetspersist-credentials: false; no job pushes with the checkout token.deploy-functions.ymlreads repository variables and step outputs throughenv:, not${{ }}inrun:.hooks/claude_event.pyandtooling/workflow.pyresolvegitwithshutil.which. - Two free, in-place Terraform changes.
allow_nested_items_to_be_public = falseon the Function host storage account, andaccess_key_metadata_writes_enabled = falseon Cosmos. - Silenced, 36 (false positives and accepted risks). Each one points
back to its row: inline
#checkov:skipand#trivy:ignorecomments,# nosecwith the reason, fingerprints in.gitleaksignore, and one entropy rule in.checkov.yaml. The repository-structure check now allows those two root files. - Left visible, 17. They need an owner decision on cost or irreversibility: private endpoints, customer-managed keys, infrastructure encryption, host storage geo-redundancy, storage logging and Flex zone redundancy.
- radarlint-iac S1135, 19. Each hit is the file name
TODO.mdin a comment. They wait on the.qlty/qlty.tomltriage rule from #568. -
#562. No unpinned
uses:exists anywhere. -
Author-written HTML can no longer claim ids the application looks up (#298). DOMPurify's default configuration strips an injected
<script id="…">but keeps an injected<div id="…">, andgetElementByIdreturns the first element with that id of any tag. Article bodies render inside the app's own markup, so an author could answer a lookup the application makes — reproduced against this repository's own DOMPurify rather than argued.
src/lib/sanitizeHtml.js enables SANITIZE_NAMED_PROPS, which prefixes
author id and name with user-content-, and an afterSanitizeAttributes
hook rewrites same-document fragment links so in-page anchors keep working.
That is what made it safe to ship without a content decision: nothing
disappears. The three call sites — RichTextBody and BlogDetailTemplate's
two branches — now share one function, because three bare DOMPurify.sanitize
calls are three configurations that can drift invisibly.
-
The hydration seed moved off an id-addressed island (#296). The seed was a
<script type="application/json" id="__PRERENDER_DATA__">found withgetElementById. By the clobbering path above, an injected<div>carrying that id sits inside#root, comes first in document order, and would have supplied the seed for its own page — an author-controlled object flowing into everyhrefandsrcon it. It now rides on the mount point, which the template writes ahead of everything the pre-render puts inside it, so nothing an author writes can precede it. This is what cleared CodeQL's 27js/xss-through-domalerts;safeUrldid not, and its header says so. -
safeUrlguards everyhrefandsrcfed from content data (#296). Allows http, https, mailto and relative references; refusesjavascript:,data:and everything else. The alerts were new and the weakness was not — the same values previously arrived fromfetch()and reached the same attributes unchecked. -
A pre-existing HTML injection in the canonical link (#296).
socialTagsinterpolated the route unescaped intohrefwhile every tag beside it went throughescapeAttr, so a content slug could put a live element in<head>. Found by an escaping test written for something else. -
The plan-check tool was PowerShell for an hour, and that was the wrong language (2026-08-29).
07-check-plan.ps1shipped in #271 and was replaced byscripts/check-tfc-plan.mjsbefore it was ever successfully run. Two reasons, both mine to have seen first. The other cutover scripts are PowerShell because they drive the Azure CLI from a Windows desktop; this one only feeds a Node script. And T-724's remaining half is running this insideiac-validate.yml— onubuntu-latest, where a.ps1is the wrong artefact. It also failed the simpler test of being runnable at the bash prompt the owner was actually sitting at:bash: ./scripts/cutover/07-check-plan.ps1: No such file or directory.
Calling checkPlan directly instead of shelling out removed three problems
the PowerShell version needed code to handle: no temp file, so the plan's
sensitive variable values and copies of state never touch disk; no deletion
path to get wrong; and no native exit-code trap, since the checker exits 1 to
mean "unexpected plan" and PowerShell 7.4+ turns that into a thrown error.
Running it found something the inspection pass could not. An invalid token
is refused with 403, not the 401 the PowerShell version special-cased —
so that branch was close to dead code and the common failure fell through to a
bare status line. The header now separates what was observed (403, reproduced
against a junk token) from what is documented but unreproduced here (404 for
an organization token, from HashiCorp's /plans reference), rather than
asserting both with equal confidence.
-
"Immediate: restore admin access" deleted; it had been fixed for some time (2026-08-29). The section described a live
403fromPOST /api/bootstrapCurrentUserAdminand asked for the EntraAdminapp role to be assigned. The owner confirmed the portal loads and signs in immediately, so the role is assigned and the 403 is gone. It had been sitting at the very top of the tracker marked Immediate — the loudest item in the document, describing something already done. Same shape as T-526, where three files insisted on work that was finished. TheAdminapp role assignment survives as one clause of the Entra row, which is where a standing requirement belongs. -
scripts/check-tfc-plan.mjs: the plan assertion in one command (2026-08-29, toward T-724).assert-expected-plan.mjshas existed since T-724 and compares a plan against the known permanent diff by resource address; what nobody had automated was getting a plan into a file to feed it, so it was run by hand or not at all. This resolves the workspace's latest run, downloads the JSON plan, runs the checker and reports the verdict.
Why the summary line cannot be the check, stated in the script: the azapi
read-then-strip pair working around azurerm#29149 means every plan reports a
diff, and the steady state is 3 add / 1 change / 3 destroy. Three different
replacements produce the same three numbers.
Two failure modes are called out because both mislead. The json-output
endpoint answers 404 for a token without workspace admin — including any
organization token — which reads like a missing plan rather than a permissions
problem. And the checker exits 1 by design to mean "unexpected plan", which
PowerShell 7.4+ turns into a thrown native-command error under
$ErrorActionPreference = 'Stop'; unsuppressed, the one outcome the script
exists to report clearly would have surfaced as a raw stack trace. Caught by
inspection, being the same trap already fixed in 05-verify-timer.ps1.
The downloaded plan carries sensitive variable values and copies of state, so
it goes straight to a temp file, never into a shell variable, and is deleted in
finally including on Ctrl-C.
- Follow-up to the
main.tfsplit: the counts it left wrong (2026-08-29). Removing T-754's row from TODO.md left the summary table readingLow | 2 / Total | 9over a list of eight, and the prose still said "seven of the nine". That is the same accurate-when-written count nobody updated that this session had already corrected twice — in Architecture_Plan §7 and in Migration-Plan's five self-counts — committed by me about an hour after fixing the second one. Corrected to 8, with T-718's and T-749's anchors repointed frommain.tftocosmos.tfandfunctionapp.tf.
wiki/Architecture-Review-2026-08.md's ~30 main.tf:NNN anchors are
deliberately not repointed. They are evidence of what a reader saw at
commit 31f9613; rewriting them to today's lines would make the record claim
something it never checked. A note at the top says they are pinned, unmaintained,
and that resources kept their addresses through the split — which is how to
find them now.
main.tfsplit by concern: 2,286 lines → 97 (2026-08-29, T-754). Seven new files —functionapp.tf,storage.tf,cosmos.tf,frontend.tf,keyvault.tf,budget.tf,network.tf— alongside theobservability.tf,oidc.tfandhub.tfthat were already carved out.main.tfkeeps what everything else references: the locals,azurerm_client_config, the resource groups, and the two telemetry sinks the diagnostics point at.
Done block by block, never by line range. The concerns were interleaved — Cosmos containers at 404 and again at 2125, storage role assignments at 1745 sitting between the azapi resources and the Key Vault — so ranges would have meant hand-arithmetic over 2,286 lines where one off-by-one silently moves half a resource. A parser took top-level blocks with their attached comments, assigned each by address, and then proved the result: the multiset of non-blank lines is identical before and after, 2,107 of them, with zero lines lost and zero non-comment lines added.
That proof earned its keep on the first run. The parser's original "a block
ends at the next line that is }" swallowed
data "azurerm_client_config" "current" {} — a single-line block — together
with the section header and the entire locals block after it, silently. The
count assertion caught it and it was rewritten to track brace depth, ignoring
braces inside strings and after a #, because a comment mentioning
admins/{oid} is otherwise an unbalanced brace.
Nothing moved between states and no resource address changed. Terraform
reads every .tf in a directory as one module, so this is invisible to state,
and an empty plan is the proof. The guards that read the configuration as text
did not need to care, because #268 had already pointed them at the whole module
rather than at main.tf — which is the entire reason that PR came first.
- Five guards asserted things about a file when they meant the module
(2026-08-29).
cors-platform-origins,secret-catalog,app-settings-secrets,timer-catalogue-syncandscripts/assert-expected-planall readinfra/main.tfby name. Terraform does not work that way — every.tfin a directory is one module, one namespace, one state — so each was checking one file and reporting on the configuration.
One of those gaps was already live. assert-expected-plan.test.mjs
matches ^resource "azapi_..." to prove its permanent-diff allowlist names
every azapi resource. An azapi resource in observability.tf, oidc.tf or
hub.tf was invisible to it: the allowlist would look complete while missing
an entry, and that resource's permanent replacement would be reported as
drift — training an operator to ignore the one tool that says the plan is
wrong. cors-platform-origins's expect(source).not.toMatch(/support_credentials
\s*=\s*true/) had the same shape, and would have passed vacuously if the
Function App ever moved.
All five now read every .tf in the root module, through
functions/test/terraform-source.js (and a deliberate eight-line duplicate in
the scripts package, which is a separate npm package with no workspace
between them). The helper refuses to run on fewer than four files, because a
wrong path yields '', and every "these two lists agree" assertion passes by
comparing two empty lists.
app-settings-secrets also stopped bounding a block with someone else's
resource. It read from app_settings = merge({ to
resource "azapi_resource_action" "function_app_settings" — a different
resource seventy lines further down that merely happened to follow. It now
ends at }), the line that actually closes the block, so where the block
ends is a property of the block. As a side effect it stops over-reading those
seventy lines into the settings it checks.
Verified by simulating the split these guards have to survive: moving the
azapi read-back into a new .tf file leaves all five green, and breaks two of
the five old ones — expected -1 to be greater than 58686 for the settings
parser, and a straight miss for the plan checker.
- REVIEW.md retired; one open-work document from here (2026-08-29). It held the owner-gated half of the open work — and every item in it was already mirrored in TODO.md under Gate: owner, because TODO's own header said it should answer "what is still open" without a second document. Two files, one restating the other.
Split by what the content actually was, rather than moved wholesale. The
four work sections and the handling rules (~75 lines) are appended to TODO.md
unabridged. PART 4 — the 228-line required-inputs inventory — became
wiki/Required-Inputs.md, keeping its §4.1…§4.10 numbering because about
sixteen code comments cite those numbers. Putting a reference inventory inside
a task tracker would have recreated the problem one level down.
REVIEW.md's header argued PART 4 sat there because the two procedures that
write to it are access-gated. That stopped holding when the file went away,
and it was always weaker than it looked: wiki/ is reviewed through pull
requests exactly as the root is.
The blanket rename was the wrong tool and I used it first. A
REVIEW.md → TODO.md sweep produced 'TODO.md', 'TODO.md' in the
validator's allowlist, a casing guard reading "Review.md and TODO.md
collide", and — the one that would have failed CI — a $requiredFile list
that still required a file being deleted. Reverted and redone with ordered
rules: §4.x citations to the new page first, then Part 4 prose, then the
§0.x/§2.x/§8.x citations, then links, then the bare filename. \bREVIEW\.md\b
throughout, so PREVIEW-SIGNING-SECRET and REVIEWER survive.
Found on the way: citations to REVIEW §0.1, §0.2, §0.4, §2.2, §2.4,
§8.1 and §8.2 were already dangling — that numbering has not existed in
REVIEW.md for a long time. Those now name TODO.md without a section number,
because a number resolving nowhere is worse than no number.
validate-repository-structure.ps1 drops REVIEW.md from the allowlist, the
casing guard and the required-file list, so it is now rejected at the root
rather than permitted. Its comments record that this consolidation has happened
twice — CHECKLIST.md and Variables.md merged into REVIEW.md in 2026-08, for the
same "several documents describing one thing, disagreeing with each other"
reason.
The root is three files: README, TODO, CHANGELOG.
- The two planning documents are closed and moved to the Wiki (2026-08-29). Both had been marked ARCHIVED since 2026-08-24 while sitting at the repository root beside TODO.md and REVIEW.md, which is where current work lives — so a reader met four documents in the same place, two of which were history.
Both last open risks closed by owner decision, and neither by being fixed — each row says so, because a risk register that records only the risks that got solved is a worse record than one that admits when a risk was accepted or became irrelevant. Cron syntax / time zone: the configuration is as correct as it can be made without arming, and settling the clock from history is now a single script run; closed as a migration risk, while arming the timers continues as T-518 in TODO.md and is unaffected. Cost overrun: a migration risk exists to inform a go/no-go, and Azure is the permanent and only environment — an overrun changes what gets built, not where it runs. Budget continues as a standing requirement on every deployment.
With those closed, Migration-Plan has no open items of its own. Its last exit criterion (§6 step 7) was already tracked as T-518.
Moved to wiki/Architecture-Plan.md and wiki/Migration-Plan.md, which is
what validate-repository-structure.ps1 already said should happen: "Narrative
documentation still belongs in the Wiki … [wiki/] is the one sanctioned in-repo
home for narrative documentation." Both names were hyphenated to match every
other Wiki page, and the root allowlist now rejects them at the root rather
than permitting them, so the move cannot quietly reverse. 29 files of citations
were updated with them, and three Wiki pages that linked out with
../../Migration_Plan.md now use the sibling page form — those links would have
404'd on the published Wiki, which is where those pages are actually read.
The four documents left at the root are exactly the four that are maintained: README, TODO, REVIEW, CHANGELOG.
- Migration-Plan disagreed with itself in four places about how much of it was left (2026-08-29). Asked what remained, the document gave five different answers: the header said "two things", §6's heading said steps 6 and 7, §7 said one of eight, §9 said "the last two", and the risk register said "three of fifteen" above a table with two live rows.
Two of those I caused an hour earlier by retiring the cost gate without following it through the document. The other two had been wrong since 2026-08-28, when T-526 closed §6 step 6 and the Telegram risk row with it. Architecture-Plan §7 already names this failure — "an accurate-when-written count that nobody updated when the list moved past it, which is the most common way a document like this goes quietly stale" — and it happened in the risk register one section below a note saying so.
All five now agree: one thing is left, §6 step 7 / §7's scheduled-job proof (T-518). The two live risk rows are updated rather than closed — the cron/time-zone row because its check no longer needs anything armed, and the cost row because its gate was retired while the risk itself was not.
- The timer verification script could not succeed, and said so reassuringly
(2026-08-29).
scripts/cutover/05-verify-timer.ps1is the tool for Migration-Plan §7's last open gate. It queriedrequeststhroughaz monitor app-insights query --app <id>— both of which the Cutover-Runbook warns against in its own words: "AppRequests is empty and is not the oracle. Zero rows for this app's entire history" (T-514), and "Query the workspace, neveraz monitor app-insights query --app <appId>… that proxy returns zero rows for every query rather than erroring". An empty table, read through an endpoint that cannot return rows.
Its answer to that was no invocations recorded, followed by "That is the
expected result while the flag is false." A reassuring sentence for a query
that was never capable of returning anything — the T-514 failure re-enacted
inside the tool built to catch it.
Rewritten to read AppTraces from the workspace, and to separate "no
evidence" from "evidence of absence": a failed query now says the query
failed, an over-cap workspace throws before any result is read as evidence,
and a cold app with no traces is reported as inconclusive rather than as a
timer that did not fire. Three defects found by inspection before it shipped —
a duplicated segment in the workspace name, a category filter that missed the
Function.<name>.User rows where disabled — skipping actually lands, and
three az calls outside try/catch that would throw past their own error
handling on PowerShell 7.4+.
- Migration-Plan's last two exit criteria are now one, and it was wrong about
the other (2026-08-29). §7's scheduled-job gate read "nothing is armed, so
nothing has been observed", which conflated two different things. Nothing has
run — true, no handler has done work. But every timer has been firing
on schedule since deploy:
app.timer()registers unconditionally and the flag is checked inside the handler. Since the clock question is entirely about firing, and the host stampsScheduleStatuswithWEBSITE_TIME_ZONEoffsets on every invocation, that half is answerable now, from history, with nothing armed and no risk. Only the handler half needs arming. Recorded with the note that a 5-minute timer cannot prove a clock at all — it fires at :00, :05, :10 in every zone — so the check needs a fixed-hour timer, and midnight'sCLEANUP_TEMP_STORAGEis both the largest Chicago/UTC gap and dry-run by default.
The cost gate is retired as an exit criterion (owner decision). It required a measurement "before decommissioning"; GCP is dead and Azure is the permanent and only environment, so that moment will not come and the gate can be neither met nor failed. Staying inside budget became a standing requirement on every deployment instead — a higher bar than one reading, not a lower one. Architecture-Plan §8 carries the same correction, and its criterion is deliberately left unstruck: striking it would read as "done and behind us".
- An API-keys page in the admin portal: paste a credential, never read one
back (2026-08-29). Rotating a key meant opening the production Key Vault's
firewall to a human IP, running
06-seed-secret.ps1from a desktop with the Azure CLI signed in, and closing the window again — three steps, one of which leaves production open to the internet if the operator is interrupted. This repository has already made that mistake once. The app never had that problem: it is inside the vault's integration subnet, whichnetwork_aclsalready admits. It was missing permission, not a network path.
Write-only, enforced below the code. The Function App's identity gets a
custom role holding exactly one data action —
Microsoft.KeyVault/vaults/secrets/setSecret/action — and not getSecret,
delete or purge. Key Vault Secrets Officer would have been one line of
Terraform and would have granted all of them. A promise the platform does not
enforce is one refactor away from being false, so a future change that tries
to read a value back gets a 403 rather than a secret. The honest limit, stated
in the code: a secret that has resolved into an app setting is in
process.env by definition, so this hides other secrets, other versions, and
anything with no reference — not the values the app is actively using.
Terraform stays the source of truth, for what it was already the source of
truth for. It declares which credentials exist and how the app finds them —
21 @Microsoft.KeyVault(SecretUri=…) references. It must not hold values:
the azapi_resource_action that reads the settings back exports the entire
live map into state and plan JSON unredacted, which is what
app-settings-secrets.test.js exists to prevent. secret-catalog.js is
checked against main.tf in CI, pair by pair, so the page can never offer a
secret the application cannot read, and the UPPER_SNAKE ↔ UPPER-KEBAB
translation that REVIEW.md §4.5 warns about cannot drift.
Four lights, where three were asked for. Gray (never seeded), green
(live), red (resolved but the upstream service rejects it) — and amber,
because App Service caches Key Vault references and "refetches them every 24
hours". For a while after a paste the vault has the new value and the running
worker does not. Green there would claim a rotation had taken effect when it
had not; gray would say "never inserted" one second after inserting it. Amber
is computed from a fact rather than a guess: environment variables are
materialised at process start, so a write later than that cannot be reflected
yet. A Microsoft.Web/sites/config/write grant, scoped to the one site and
deliberately excluding config/list/action (the action that reads settings
back), lets the app force the platform to re-resolve, so new workers pick a
key up immediately instead of within a day. That call is best-effort: if it
is refused, the secret is already written and the only cost is the slower
cycle.
Red is real, not decorative. secrets-health.js says in its own header
that it cannot see "a setting whose reference resolves to the WRONG secret …
only the upstream service can say it is wrong". The AI router now says it: it
already separated a rejected key (401/403) from a bad request, and reports
that verdict. 404 and 429 are deliberately not reported — a wrong model id and
a busy account are not a bad credential, and would send an operator rotating a
key that is fine. Successes are recorded once per worker per provider, so the
bookkeeping stays off the hot path of every generation. Only the three router
providers may claim a liveness check, and a test binds the two lists: the page
prints "no liveness check for this one" beside every green light that has
none, rather than implying a check it does not run.
Ten mutants confirm the guards fail closed, including two that survived a first pass and exposed real gaps: reading the environment before the write timestamp (which made a first paste show gray — the exact "looks broken" failure amber prevents), and spreading the state record into the response instead of naming its fields.
- GCP pricing moves from a service-account JSON to the API key Google documents, and takes three dependencies out with it (2026-08-29). The Cloud Billing Catalog API serves the public price list. Google's own guide says what it wants: "Before you can use the Cloud Billing Catalog API, you'll need to enable the Cloud Billing API and get an API key." This estate was doing a signed-JWT token exchange instead.
What that cost. The credential was a ~2.3 KB multi-line service-account
JSON, which cannot be an app setting, so it needed a run-time vault client to
fetch it — and that client needed @azure/keyvault-secrets, a KEY_VAULT_URI
app setting, a data-plane RBAC grant on the production vault, and
functions/src/lib/key-vault.js. The token exchange needed
google-auth-library. The --file seeding requirement needed
scripts/cutover/03-keyvault-secrets.ps1, a whole script for one secret. Six
moving parts, one caller, to read prices anyone can read.
What replaced it. GCP_BILLING_API_KEY, an app setting holding
@Microsoft.KeyVault(…secrets/GCP-BILLING-API-KEY) like every other
credential here, read through readKey — which already treats an unresolved
reference as absent. The key travels as a key query parameter on both the
services list and every SKU page; there is no Authorization header left in
the module. Absence still throws, unchanged and deliberately: returning
null would be indistinguishable from "GCP has no price for this service" and
would quietly become a baseline row, and a comparison table showing stale
numbers is worse than one that errors.
What is gone. functions/src/lib/key-vault.js and its test (zero call
sites once gcp.js stopped importing getSecret), @azure/keyvault-secrets,
google-auth-library, the KEY_VAULT_URI app setting, and
scripts/cutover/03-keyvault-secrets.ps1 — superseded by 06-seed-secret.ps1,
which seeds any secret and now has no exception to carry. Every secret in the
estate reaches the app the same way, which is why
app-settings-secrets.test.js's allowlist of secret-shaped settings that are
not vault references is now empty. main.tf declares 21 references and no
run-time reads.
This module is an information tool, not a billing integration. It is read by the public pricing comparison so a visitor can see three clouds side by side; this estate runs on Azure and is not charged through anything here. The earlier design treated a public catalog lookup as if it were account access, and the grant, the SDK and the script all followed from that one mistake.
Four mutants confirm the new guards fail closed: bypassing readKey so an
unresolved @Microsoft.KeyVault(…) string goes out as ?key=, removing the
throw on absence, dropping the key from the SKU page URL, and putting it back
in a bearer header. Each is caught.
- The architecture review's remaining Medium and Low findings, 20 more
closed (2026-08-28; #257, #258 and this change). The review's own tally
goes 35 → 55 of 62. What is left is seven: T-714 (an owner decision on
hydration), T-718 and T-728 (each needs an Azure apply to mean anything),
T-719 and T-721 (measurements and a cost decision), T-749 (a workspace flip),
and T-754 (splitting
main.tf, which the review itself asks for in a PR whose plan shows nothing else).
The gate was not checking what it was believed to check. vps-agent —
the component that shells to docker run with a payload from the platform
API and holds a certificate on a third-party VPS — had its CI row run
npm ci and stop, so an edit dropping --network none shipped green. Its
sandbox flag list is the entire security boundary and now has 37 tests over
the argv it produces, plus a refusal for any capability that tries to set a
sandbox-controlled flag: Docker is last-wins, so extraDockerArgs could
previously have overridden the boundary silently. Job images move from
mutable tags to digests, because docker run pulls before any sandbox flag
applies. Separately, the harness check drove every node through the
unavailable-agent branch, so the handoff validator it exists to protect was
never called — root cause being that no agent definition is committed, so no
node could ever be available.
Two unbounded runtime paths got bounds. The change feed handed 50
documents to an invocation in which one content document can require four
Replicate generations, an inspection, a caption and a Publer call; it now
takes 8 and carries a wall-clock budget that throws rather than returns,
because returning would advance the lease past documents it never looked at
and their triggers would never fire again. And the daily forge budget — the
system's only AI-spend ceiling — was enforced in one place, against a count
written by a best-effort write that swallows its own failures, computed once
before a loop so a concurrent manual forge was invisible, and incremented
after the model calls so a run killed mid-flight spent tokens the ledger
never recorded. It is now a server-side compare-and-increment taken before
any model call.
Two live user-visible frontend bugs. An anonymous visitor to
/{provider}/news downloaded and executed 236 kB of MSAL to look at a news
grid: the hook's own header claimed its role gate prevented that, but a
runtime gate cannot stop a bundler resolving a static import. Measured
against real builds, the route's static closure drops 1,060,504 → 816,158
bytes. And four copies of provider canonicalisation had drifted — one knew
vmware/ansible, another did not — so those documents appeared on the
landing page and vanished from their own provider's blog list.
Claims became checks. outputs.tf said sensitive key outputs are
"intentionally omitted" eleven lines above one that is not. A comment said a
plan of a particular shape "and nothing else means NO DRIFT", where "and
nothing else" is the whole claim and the part a human reading a summary line
cannot verify — three destroys look like three destroys whichever three they
are. The azapi read-back exports the entire live app-settings map into state,
safe only while every secret-shaped setting is a Key Vault reference, which
nothing asserted. Each is now a test or a program, and the two genuinely
accepted exposures — the SWA token and cloudflare_origin_secret — are
recorded in REVIEW.md with their rotation consequences rather than left to be
rediscovered.
Four Key Vault failures stopped being one symptom. Unseeded, RBAC
revoked, firewall denying, and rotated-then-broken all arrive as the literal
@Microsoft.KeyVault(…) string, which the code correctly treats as "no key"
— so a feature turns itself off in production, indefinitely, with no
exception in Application Insights because the path taken is a clean fallback.
/api/health now reports how many settings are in that state: a count rather
than the names, because that endpoint is anonymous and T-402 already
established that an unauthenticated inventory is what host enumeration looks
for.
Three things the review got wrong, and one it under-stated. T-757 claimed
the frontend gate runs an admin subset; test:admin is plain vitest run
with no include filter and has been since T-320 closed exactly that — the
finding was inferred from the script's name, so the name is gone. T-731's
prescribed "return early" would have silently dropped documents. T-724's
recommended CI check cannot be written without a TFC token the repository
does not have. And T-738 under-stated itself: normalizeContentProvider
matched exact keys, so "Microsoft Azure" became microsoftazure and fed
getContentPublicPath, building public URLs no route serves — for the normal
case, since multi-word provider fields are what documents actually carry.
Mutation testing found two tests that were green while checking nothing,
and both are worth recording because the failure mode is invisible in review.
The sandbox test compared each flag against the module's own constant — the
same one a regression would edit — so --user 0:0 passed. The route-shadowing
guard compared absolute paths to each other, so reintroducing
/terraform/code did not fail, because it duplicates a relative child of
/:provider, which is the actual shape of the bug. A third was found by
updating it: the curated-image hook's tests were exercising the new fallback
path rather than the new path, because the batched function was missing from
the mock factory.
- The architecture review, and 35 of its 62 findings fixed (T-701–T-762,
2026-08-28; #249 records, #250 remediates). Six specialist reviews, one per
technology layer, run against merged main: Azure platform, Terraform IaC,
backend Functions, frontend React, CI/CD, and the remaining ops surfaces
(Cloudflare Worker, PowerShell scripts, Python harness, VPS agent). Every
finding carries a
file:lineanchor; the review of record, with each finding's failure mode, recommendation and outcome, is wiki/Architecture-Review-2026-08.md.
All five Critical findings are closed. An editor could publish content
live by enqueuing a job, because registerJobType defaulted the required role
to editor and no job type declared one — so publish-content bypassed
the publisher gate its HTTP twin enforces. Both PowerShell confirmation
gates self-approved whenever stdin was not a TTY, which made
bootstrap-terraform-oidc.ps1 -ElevateAccess < /dev/null reach tenant-root
User Access Administrator unprompted; the code that then removes that grant
reported "removed (verified)" over a grant that might still be live, because
its read-back could not tell a failed call from an empty result. The T-526
cutover script's -WhatIf mutated the production Key Vault rather than
simulating, and its "custom secret: set" line was unconditionally true —
both fixed, so T-526 is now safe to run. And the production environment
gates nothing while workflow_dispatch accepts any ref, which is now
backstopped by a main-only guard in both deploy workflows.
Data durability. Media storage moves LRS → RA-GRS: ADR 0018 accepted LRS
only "while the Firebase source retains the authoritative copy", and ADR 0023
removed that copy. RA-GRS rather than ZRS deliberately — the risk is account
and regional loss, which zone redundancy does not cover, and LRS→ZRS is not
Terraform-expressible. Cosmos backup moves to the 30-day tier, and
prevent_destroy now covers the database and containers, which are generated
from a spec file with immutable partition keys.
Also fixed: jobs stranded in running forever, a 2,000-point-read
fan-out reachable from any Telegram message, missing timeouts on every
outbound call in the change-feed path, a Telegram send failure that produced
duplicate publish jobs, an SSRF gap on scraped image URLs, an alert window
with no ingestion-lag headroom, and a 297 kB reduction in what every page
preloads.
Two of the review's own findings were wrong, and that is recorded rather than quietly dropped. T-750 asked for CORS origins to be derived from variables; doing so broke a guard that reads those literals as text to keep the platform and application allowlists in sync — the drift it prevents is a dated outage. T-715 correctly diagnosed a 456 kB bundle on the critical path but prescribed a fix that rolldown ignores; measurement found what actually worked.
One finding exposed something larger than itself. T-747 — the lowest-value item in the review, "add a directory to Dependabot, zero impact today" — made Dependabot re-validate its config, which failed: an unsupported property on the terraform entry had invalidated the whole file, so no ecosystem had been receiving dependency updates at all. Latent because Dependabot re-validates only when the file changes.
- Five of the Blog Machine's six backlog items landed after the program closed (2026-08-28; #243–#248). Recorded here on 2026-08-28 for the same reason as the entry below: they shipped without changelog entries. The backlog was written as "unique and cool, deliberately unscheduled", and then most of it turned out to be small once the program's machinery existed — which is the useful fact, not the features individually.
Four of them were dead frontend calls, not new ideas. The admin UI had
been posting at generateSocialCaption, generatePreviewImages,
generateCuratedArticleImage, generateReviewHeroImage,
triggerAiImageGeneration and generateReviewerDigestManual since the
import, and every one returned a 404. Each is now implemented against the
contract the caller already sends, and each shares an existing core rather
than growing a second implementation: the four image RPCs call ai-cover's
extracted generateCoversForContent, so a manual hero and an automatic one
are indistinguishable in the document; the digest RPC calls the 07:00 timer's
own snapshot with generatedBy: 'manual', merging into the same
workflow_digests/{date} document. Backlog item 3 also carried a wrong
guess about its own contract — the "Run Now" tile is the reviewer queue
snapshot, not the newsletter job — and the correction is recorded on the
wiki page rather than quietly implemented.
Publishing now queues its own social post. A first live publish arms
socialCaptionTrigger once per document (a republish never posts again, and
staging posts nothing), the change feed generates a practitioner-voiced
caption and bulk-schedules it in Publer after a delay that defaults to 60
minutes — deliberately, as the undo window. It is switched entirely by the
owner-seeded admin_config/social_autopost: absent or disabled means no
model call at all, and Publer unconfigured degrades to a Social Hub draft.
The forge proposes its own interlinking. After post-processing but
before grading, so the grader sees the real article, every published post is
scored against the draft with Jaccard over title and keyTopics tokens,
reusing the dedupe gate's own tokenizer. The top three above a 0.15 floor
become a deletable "related reading" module plus relatedContentIds series
metadata. Only posts with a resolvable public URL are ever proposed — a link
the reader cannot follow is worse than no link — and no score ceiling is
needed, because true duplicates never reach this code: the dedupe gate
already refused to forge them.
And the grader gained an advisory SEO lint (lib/content/seo-lint.js,
no model call): summary length against the 70–160 meta-description window,
slug/key-topic alignment against the real slugify the publisher uses, and
heading hierarchy with code fences and module tags stripped first. Strictly
advisory — findings never move the overall grade, the staging decision or any
publish gate. They surface where the approve decision is actually made: as a
bullet list in the preview banner and a one-line count in the forge_ready
Telegram note.
Backlog item 4, analytics-informed topic weighting, is not landed and is not an engineering task: no engagement data source exists anywhere in the system. It needs an owner to pick an analytics provider before it is buildable at all.
- Reachability gets an alert, from the edge rather than from Azure (T-519
repository half, 2026-08-27; #234, ADR 0024).
Recorded here on 2026-08-28 — it shipped without a changelog entry, which is
the gap this documentation pass found. Reachability was the one signal with
nothing behind it, and it is the signal that matters most: every other alert
needs the app healthy enough to emit telemetry, so it is the only one that
survives the app being completely down. Azure's own availability test could
not provide it. Cloudflare's Bot Fight Mode answers datacenter clients — which
is exactly what Azure's availability agents are — with a 403 interstitial on
https://api-azure.<domain>/api/health, and the WAF skip rule written against it was built, applied and confirmed inert, because Bot Fight Mode does not run on the Ruleset Engine and is not configurable on the free plan.
ADR 0024 routes around it instead of waiting on it. edge/availability-probe
is a Cloudflare Worker on a five-minute cron whose same-zone subrequest is
never challenged; it reports every attempt to Application Insights, and
edge_probe_availability counts successes and fires below two in a
fifteen-minute window. Counting successes rather than failures is the point:
it makes a dead probe and an unreachable API the same incident, where a
failure-counting rule would go quiet on both. Both the alert and the original
standard web test stay gated (availability_probe_alert_enabled and
availability_test_enabled, each defaulting to false), so the repository
ships armed-but-inert; deploying the Worker and seeding its connection string
are owner actions, and the procedure is
wiki/Availability-Probe.md. The web test is kept
in Terraform, disarmed, for the day the Cloudflare plan is upgraded.
- The Blog Machine (T-601–T-607 closed, 2026-08-28; #236–#242 + the close-out PR). One initiative, seven phases, each one PR, turning the admin portal into a content engine around the already-working forge pipeline: check posts in the queue or paste a URL, and a professional, visually rich post in the owner's voice comes out the other end — announced on Telegram with a signed staging link, approved with one reply, published live through the full gated pipeline. The program of record with per-phase as-built notes is wiki/Blog-Machine.md.
What landed, phase by phase: the forge's only entry point fixed (its
payload key had always been wrong — the test asserted the job type, not
the key; both are pinned now) and modular posts regained syntax
highlighting (T-601, #236). Paste-a-URL: generateArticleDraft
HTTP-direct plus the unattended forge-from-url job, with the
voice/format prompt extracted to one voice.js builder (T-602, #237).
"Forge Selected (n)" on the review queue with select-all and
grade/provenance badges (T-603, #238). Forge Studio — wordSoup,
weighted interest areas, master prompt, banned phrases, style rules,
publish threshold, autoForge — over new getForgeConfig/
updateForgeConfig RPCs, with voice calibration as accept/dismiss
suggestions never merged automatically; retired the manual-Cosmos-seeding
requirement (T-604, #239). Five rich module types (pull_quote,
stat_board, comparison, timeline, callout) across the shared
grammar, validation/repair, renderers, editor and forge prompt, fixing the
design-module round-trip data loss on the way; MAX_MODULES 10→14 with
list-contract tests on both sides (T-605, #240). The approval loop:
the HMAC-signed /api/public/preview/{id}?t=… route (72 h,
byte-identical 404 for every refusal) with the /preview/:id page, the
forge_ready rising-edge Telegram notification, /approve publishing
through the injected processPublishContent (every gate applies) and
/reject running the extracted state-machine core — plus curated default
heroes when AI cover generation fails (T-606, #241). And throughput:
one forge_stats.today ledger enforcing autoForge.dailyLimit across
scheduled and manual forging alike, interest-weighted candidate ranking,
failure-only job notifications via a new jobs onComplete hook, grade
sorting and a forged-today meter on the queue, and a staged-for-approval
list in the bot's /queue (T-607, #242 + close-out).
Two live bugs fixed in passing, both of the same shape — a call that
looked right and a test that asserted the wrong half: the bot's
/ack//resolve had never persisted (alertId passed as patchDoc's
updates argument), and /forge had never enqueued a runnable job.
Activation is owner-gated and deliberate: T-526 (webhook
re-registration — the loop is silent until then), T-518 (timer arming),
PREVIEW-SIGNING-SECRET and REPLICATE-API-KEY seeding, and the ~8
default-hero uploads. The code ships dark and arms itself as each gate
opens; nothing publishes without the owner's explicit /approve from the
verified chat.
- The alert fabric can now be verified against what is actually deployed.
.github/workflows/verify-alert-state.ymlreads the three workload rules through ARM with the existing OIDC deployment identity and printsautoMitigate, mute duration, frequency, window and severity into the job summary; a stateless or missing rule fails the run and is named in the table.
It closes a gap that only became visible while trying to confirm the previous
entry had taken effect. Applies run in HCP Terraform Cloud on a human's
confirmation, and a green TFC run proves ARM accepted the change — not
that the deployed rule behaves differently. Those come apart exactly at
autoMitigate, the attribute that decides whether a firing rule mails once or
every five minutes, and which is invisible from the repository, from CI and
from the TFC run list. Nothing short of asking ARM answers it.
The identity's new grant is Monitoring Reader on rg-web-site-prod-cus
(infra/oidc.tf), chosen over Reader deliberately: both carry the */read
the operation needs, and Monitoring Reader does not carry listKeys on the
workspace — so the identity cannot read ingestion keys and cannot forge or
drown the telemetry the rules evaluate. That is the same reasoning that chose
Log Analytics Reader for the alert rules' own identities in ADR 0022 decision
4. Scope is the resource group rather than the individual rules, so renaming a
rule cannot silently break the verification path.
Two constraints shaped it. The job declares no environment: — doing so
changes the OIDC subject GitHub presents from ref: to environment:, which
the branch credential cannot match (the AADSTS700213 failure documented in
infra/oidc.tf). And it is workflow_dispatch only: this reads production,
so a human decides when.
Note the sequencing — the workflow cannot report until the apply carrying
its own role assignment has landed. Until then it fails at azure/login or
on the read, which is the self-arming behaviour the vars.CLIENT_ID gate
already models elsewhere.
- Listen & Learn (T-411). One study podcast per weighted skill area of a
certification's official study guide, with the videos worth watching next.
Ported from Site-Main
functions/listen-and-learn/(088f458) with the pipeline intact — study guide → skill areas → videos per area → dialogue → MP3 → draft — and three deliberate departures:
Audio is Gemini TTS, on the key the site already holds. Upstream
authenticated to Cloud Text-to-Speech with Application Default Credentials, a
GCP identity this Function App cannot hold — the same reason Vertex was
dropped from the AI router. The replacement is the Gemini API's own
multi-speaker TTS through GEMINI_API_KEY, which is the capability the
feature was actually for: a two-host deep dive read from source material is
what a NotebookLM audio overview is, and these are the models that produce it.
It costs no new service, no new resource and no new credential. Its contract
shapes the module in three ways: multi-speaker takes at most two speakers,
which is exactly the number here; the dialogue is a prompt rather than markup,
so the transcript's speaker labels must match the speech_config names or a
label is read aloud instead of switching voice; and a 32k-token session
comfortably holds a whole 9,000-byte script, so an episode is one request with
nothing to chunk.
Episode audio is encoded to MP3, and that is not cosmetic. Gemini returns
headerless 24 kHz 16-bit mono PCM with no format option — 48 KB per second, so
a nine-minute episode is about 26 MB. readBlobForDelivery buffers a whole
blob into memory and the media route returns it as one body with no range
support, so that would hold 26 MB per concurrent listener, bill Function
execution for the entire transfer, and make the player wait for the whole file
before starting. At 64 kbps mono the same episode is about 4.5 MB (measured
5.8x on a 24 kHz tone). @breezystack/lamejs is the encoder: pure JavaScript,
no native build, no dependencies of its own; it is LGPL-3.0 and imported
unmodified, and it is the only copyleft dependency in the package.
Azure AI Speech is kept as the fallback, not deleted. Every Gemini TTS model
is a preview model, and preview endpoints get retired on notice. Azure
Speech is GA, and having the second path written and tested is the difference
between a model retirement being a config change and being an outage. It needs
a Cognitive Services resource, which is a spend decision, so nothing assumes
one exists and an unseeded AZURE_SPEECH_KEY simply means the provider is not
offered. Its own hazard is pinned by tests: that REST API silently TRUNCATES
at ten minutes of audio rather than erroring, so its dialogue chunking
estimates duration with a slow voice — being wrong the other way deletes the
end of an episode and looks like a complete one.
Provider selection follows the AI router's rule — a key makes a provider
possible, and the first configured one in preference order runs — with
LISTEN_AND_LEARN_TTS_PROVIDER to pin one outright. A pin that is not
configured FAILS rather than falling through, because falling through would
produce episodes in a voice nobody chose. Both providers return MP3, so the
blob path, the stored contentType and the <audio> element are identical
whichever ran. Each episode records speechProvider, speechModel and
durationSeconds — provenance for AI-generated study content, and the only
thing that answers "why does this one sound different" after a model change.
The script no longer asserts the hosts' gender. Upstream's prompt said both hosts were women because the Google voices it used were documented female. The Gemini voice list publishes a descriptor per voice and no gender at all, so the claim is not one the audio can keep. The default pairing is by descriptor and follows the roles the prompt already assigns — Kore (Firm) leads and frames, Leda (Youthful) asks the question a learner would ask — and both are overridable per host.
Generation is a job, not a request. An Azure Functions HTTP response is
bounded at 230 seconds by the load balancer and one certification is five
model calls, five syntheses and five uploads, so the admin page enqueues
generate-listen-and-learn and polls, as the RSS ingest does. The run still
saves area by area, so a timeout leaves the finished episodes behind.
A missing speech key degrades; a broken one fails. Upstream treated any
synthesis failure as a failed area. SpeechNotConfiguredError — no provider
configured — now still saves the episode with its transcript,
takeaways and videos and records audioError, which the admin page renders
in place of the player. Every other synthesis failure still fails the area,
because those are faults to fix rather than a state to ship in. The feature
is useful the day it deploys and gains audio the day the key lands.
Approval is unchanged and is the point: episodes are AI-written summaries of a
paid exam's objectives, generated as drafts, and GET
/api/public/listen-and-learn filters on status === 'published' with an
equality test — an unrecognised status stays hidden. listen_and_learn and
listen_and_learn_episodes already existed in the Cosmos container spec;
listenandlearn is a new private blob container served through the media
route. GitHub exams are enabled alongside Azure and AWS, since they are
hosted on Microsoft Learn and parse with the same adapter.
- draw.io hotspot tooling (T-410). lib/drawio/parseDrawio.js and
lib/drawio/hotspotGeometry.js port from Site-Main unchanged, with their
test and fixture. A hotspot now stores a draw.io shape id and its position
is derived from the diagram XML on every render (useResolvedHotspots), so
re-uploading an edited diagram moves every pin with its shape instead of
stranding it. DiagramSourcePanel replaces typing x/y percentages into two
number inputs and eyeballing the result. Nothing renders the .drawio file —
vendor stencils are most of what makes a cloud diagram readable, and the
exported image stays what visitors see. Hand-positioned hotspots, the only
kind this repository could write before, pass through untouched, and the
admin preview now resolves through the same code as the public page.
- The data-migration workflow can no longer publish production data. The
repository is public, and
migrate-data.ymluploadedscripts/reports/— document ids and 240-character field samples — as a workflow artifact, while the import dry-run printed document samples to the job log. Every migration script now writes a*.summary.json(counts, container names, warning tallies) beside its full report; only summaries are uploaded, with 1-day retention; the export lives in$RUNNER_TEMPand dies with the runner;MIGRATION_CI=1makes--show-samplesan error; and the upload step refuses any non-summary JSON it finds. - No stored credential on either cloud for the migration. Firestore and
GCS reads authenticate through Workload Identity Federation
(
google-github-actions/auth+applicationDefault());connectFirestore()refuses aservice_accountkey file in CI,connectCosmos()refuses to start ifCOSMOS_KEYis set, andFIREBASE_SERVICE_ACCOUNT_JSONis retired before it was ever provisioned. Theazcopystorage script — whose GCS source accepts only a downloaded key — was replaced by a Node copier on the same federated credentials. - Production Cosmos is locked by RBAC, not by a YAML guard. The deploy
identity's database-scope Data Contributor and blob-write roles on
production exist only behind
migration_writer_enabled(defaultfalse); the workflow's refusal oftarget=productionfor write modes is the second lock, not the only one. -
Every GitHub Actions reference pinned to a commit SHA — 35
uses:lines across all 12 workflows. A tag is a mutable pointer: whoever controls the action's repository can move@v4to different code at any time, and the March 2026 Aqua incident that broke this repository's Trivy step was exactly that. CodeQL's Actions pack raises one alert per unpinned third-party reference, so this closes that class of finding as well as the real supply-chain exposure. Each pin carries the version it was cut from as a trailing comment (@ff2f1c6… # v4), because a bare 40-character hex string tells a reviewer nothing; Dependabot'sgithub-actionsecosystem was already configured and keeps SHA pins current the same way it keeps tags current. The repository standard already required this — oneactions/checkoutreference had been pinned and the other eleven had not. -
Anonymously submitted HTML is sanitized on ingest.
overviewHtmlarrives through the anonymous submission endpoint, is stored, and is eventually rendered withdangerouslySetInnerHTMLon a public template — with a single client-sideDOMPurify.sanitize()call standing between those two facts. Sanitizing at ingest makes safety a property of the stored data rather than of one component's rendering choice; the client-side call stays, because two layers is the point. No dependency was added: the sanitizer usescheerio, already in the tree, so it parses rather than pattern-matching markup. (TODO.md T-408) - The Content-Security-Policy stopped granting the Firebase/GCP surface, and
started granting Entra.
connect-srcstill allowed*.googleapis.com,*.firebaseio.com,*.cloudfunctions.net,*.run.appandwss://*.firebaseio.comlong after the last Firebase import was deleted, plus*.documents.azure.com, which contradicts the rule that the browser never holds a Cosmos client. More consequential in the other direction:login.microsoftonline.comwas absent fromconnect-srcandframe-src, so admin sign-in and MSAL's silent token renewal could not have worked at all. Pinned bycsp.test.js, since a CSP failure appears only in a browser console on a deployed site. (TODO.md T-404) - Key Vault failures are no longer indistinguishable from a missing secret.
Throttled, RBAC-denied and unreachable all returned
null, the same value as "this secret does not exist" — and the one caller turnsnullinto an error message naming the wrong cause.nullnow means absent and everything else throws, carrying the real reason. Reads are cached for five minutes, so a hot path no longer spends the vault's request budget on a value that changes approximately never. (TODO.md T-405) GET /api/healthstopped reporting the runtime. It returnedprocess.versionand the deployment name to anyone — an unauthenticated inventory of exactly what an attacker enumerates first, and of no use to a liveness probe. (TODO.md T-402)- The role cache is bounded. It is only reachable after a token verifies, so an anonymous caller could not grow it, but it had no eviction at all and grew with every distinct principal that ever signed in. (TODO.md T-408)
- Authorization-denial auditing is pinned by a test. The
admin_audit_logswriter already existed in the guard's production composition — the finding was stale — but nothing failed if the line were deleted. Now something does. (TODO.md T-406) - The Cosmos account primary key is out of app settings.
COSMOS_CONNECTION_STRINGcarried it — readable by anyone with Contributor on the resource group, and present in Terraform state — for the sole benefit of a change-feed trigger binding whose two handlers were empty TODOs that nonetheless ran continuously and billed lease-container RU. The handlers, the registrations and the setting are all gone, and the route-inventory test now asserts zero change-feed registrations so reinstating one is a visible decision. It was also masking a real risk: the binding kept working off the key whilecosmos-client.js, which uses managed identity, would have returned 403 on every call if its role assignment were wrong — a half-working app is harder to diagnose than a uniformly broken one. (TODO.md T-315) - The Labs VPS agent no longer holds a database credential. It ran on a
third-party host with a Cosmos account primary key — read/write over all
71 containers. It now authenticates to the Functions API with an Entra
certificate and can reach three endpoints, each constrained server-side:
claims are limited to the job types its registry document lists, results can
only be written for jobs it currently holds, and
cancelledis not a status it may report. Revocation is a field on the registry document and takes effect on the next call, with no cache in between. The rejected alternative and what still needs provisioning are recorded in REVIEW.md §0.4. (TODO.md T-401) - CORS applied to every route.
lib/auth/http-route.jsis now the single registration helper for all 59 HTTP routes: it registersOPTIONS, evaluates CORS before the handler runs, and merges the headers onto every response including errors. Previouslycors.evaluatewas called by one route of fifty-eight, and the advertised method list predated the REST surface, so a browser preflighting any of the fourteenPUT/PATCH/DELETEroutes would have refused to send. (TODO.md T-102) - Route-inventory test added — the replacement for the
firestore.rulesdefault-deny catch-all that Azure has no equivalent of, and the testrequire-role.jsdeclared in its header and never had. Every registration must be guarded or named in an explicit eight-entry public allowlist, must acceptOPTIONS, and must evaluate CORS. Verified by mutation: an unguarded route and a rawapp.httpregistration both fail it. (TODO.md T-103) - Dependency advisories cleared —
dompurifyto^3.4.13(moderate: XSS via detached subtree afterIN_PLACEhook removal; ships in the app bundle),nanoidoverride^3.3.18(high),js-yamloverride^4.3.1(high). Both packages report zero advisories. (#67) - Anonymous write path closed — public submissions now pass server-side validation and quota enforcement instead of writing directly to the content collection from the browser. (#45, #66)
oauthTokenmade write-only on every read path formcp_servers. (#47, #62)- Upload path hardened — container allowlist, traversal-resistant blob path validation, and a decoded size cap enforced before storage is touched. (#62)
- Snapshot endpoint allowlisted to
certificationsandspeakereventsso it cannot become a generic container read. (#45) speakereventssnapshots no longer publish admin emails or hidden events.SANITIZERShad acertificationsentry and none forspeakerevents, so raw rows were written into_snapshots/speakereventsand served anonymously — includingcreatedBy/updatedBy, which carry the email of every admin who touched an event, anddisplay: falserecords whose only filter was client-side. A positive field allowlist now governs what is published, andgetSnapshotstrips internal fields insideitems[]rather than on the wrapper alone. A test asserts every snapshot collection has a sanitizer. Takes effect on the nextpublishSnapshotrun — an already-published snapshot keeps its contents until then. (TODO.md T-201)- Soft-deleted podcasts, cache documents and AI insights are no longer served
anonymously.
listPodcastsandgetFeedapplied no deletion filter, andai_insightswas filtered onactive !== falseonly — so a soft-deleted insight still reached the news feed.isSoftDeletedwas extracted as the portion ofisPublicDocumentthat applies to collections with no editorial workflow, and both handlers now use it. The full predicate was deliberately not applied: these three collections carry no publication status, so it would have emptied the podcasts page, the news feed and the insights panel. (TODO.md T-202) - The anonymous feed endpoint is bounded.
getFeedranSELECT * FROM c WHERE c.provider = @provideragainst bothrss_cacheandai_insightswith no ceiling, andqueryDocscalls.fetchAll(). Both now cap at 200 documents — a runaway guard, not a page size: onerss_cachedocument is one feed, so sizing the bound to the 30 items the client renders would have dropped whole feeds. Items within a document remain unbounded, tracked as T-319. (TODO.md T-203) - Point reads against the four non-
/idcontainers now fail loudly.readDoc/patchDoc/deleteDoc/replaceDocIfMatchdefaulted the partition key to the document id, which forcontent_versions,image_prompt_sets_prompts,image_prompts_setsandlisten_and_learn_episodesreads the wrong logical partition and returns nothing — surfacing as a permanentnull. They now throw unless given an explicit key, and a test keeps the map in step withinfra/cosmos-containers.json. (TODO.md T-313) putConfigno longer deletes stored OAuth tokens. It is a full replace and reads never returnoauthToken, so any read-modify-write round trip from an edit form would have wiped it. The token is carried forward unless explicitly supplied; an explicit empty string still revokes. The read-sidehasOauthTokenboolean is stripped from incoming bodies. (TODO.md T-314)cms/contentlist rejects a malformedlimit.?limit=abcproducedTOP NaN— a 500 carrying raw Cosmos error text — and?limit=0produced a silently empty list. Clamped like its four siblings, anderror.messageno longer reaches the client on any of the file's 500 paths. (TODO.md T-310)deleteSetArtifactsqueries one logical partition instead of fanning out across all of them;queryDocsgained an optionalpartitionKey. (TODO.md T-312)- Uploads no longer accept an arbitrary content type.
contentTypewas taken verbatim from the body and stored as the blob's Content-Type, which the media route serves back: an editor could hostevil.htmlastext/htmlon an org-owned domain. Six image types are now allowed, each having to agree with the path's extension —badge.pngdeclaredtext/htmlandevil.htmldeclaredimage/pngare both refused.image/svg+xmlis accepted only into containers the anonymous route does not serve, since an SVG on a public URL is a scriptable document in the storage origin andnosniffdoes not address a type that was declared rather than guessed. (TODO.md T-307) - A caller-chosen upload path can no longer replace a live asset. Uploads
from the admin route are conditioned on
If-None-Match: *and answer 409 instead of overwriting;uploadBlob's default is unchanged, so the paths that rewrite deterministic keys on purpose still do. The condition is asserted against a mocked SDK rather than only against the handler's fake storage — that fake is what let T-104 stay green while every real upload threw. (TODO.md T-307) - Upload size is checked before memory is committed. The 413 came after a
full JSON parse, a full base64 string and a full
Bufferdecode — roughly a 250 MB peak for a 100 MB body on a 2048 MB instance.Content-Lengthis now checked before the body is read anddataBase64.lengthbefore it is decoded, with the decoded count still the final authority. There is nohttp.maxRequestBodySizeinhost.jsonto complement this; the v2+extensions.httpschema has no such key, so the anonymous submissions parse still needs its own check. (TODO.md T-306)
Infrastructure¶
- Storage:
Storage Blob Delegatorrole assignment for user-delegation SAS; media containers declaredprivate, matching the account-level override that already made them so.
Authored but never applied — no Terraform validate, plan, or apply had
run from any session (recorded at the time in REVIEW.md §1.1, since retired).
- Cosmos DB serverless container specification (71 containers).
- Flex Consumption plan and pricing work. (#38, #41, #42)
- Container Apps Job definition for the CI runner. (#48)
Notes¶
- Work merged before the SOP was adopted has been reconstructed from pull request history; entries reference PR numbers rather than release tags.
- Nothing in this file has been verified against a deployed environment.