Architecture review — 2026-08-28¶
CLOSED 2026-08-31. This page is a dated record, not a live list. All 62 findings are resolved or handed to the owner. The three that still need an owner —
T-719,T-721andT-749— are stated in full in TODO.md rather than by reference to this page, so nobody has to read two documents to know what is open. Do not add findings here; a new review is a new dated page.
The review of record for the six-layer architecture review opened on 2026-08-28. This page carries the evidence, the failure mode, and the recommendation for each finding, organised by the layer an engineer would be working in rather than by severity.
Findings keep the T-7NN identifiers TODO.md assigns. Nothing here is fixed;
recording and fixing in one change would make the record unreviewable.
The
file:lineanchors below are pinned to merged main at31f9613, the commit this review ran against, and are not maintained. They were already drifting —main.tfalone grew from 2,037 to 2,286 lines while these findings were being worked — and on 2026-08-29 T-754 split that file intofunctionapp.tf,storage.tf,cosmos.tf,frontend.tf,keyvault.tf,budget.tf,network.tfand a 97-linemain.tf, so aninfra/main.tf:1174now resolves to nothing at all.They are left exactly as written. An anchor is evidence of what a reader saw at a stated commit; rewriting it to today's line numbers would make the record claim something it never checked. Search for the resource by address instead — every one of them kept its address through the split, which is what made the split safe.
Method¶
Six specialist reviews were run in parallel against merged main at
31f9613, one per technology layer:
| Layer | Surface reviewed |
|---|---|
| Azure platform | infra/*.tf topology, identity, secrets flow, resilience, observability, cost |
| Terraform IaC | the same files as code — module shape, pinning, state safety, drift |
| Backend | functions/ — routes, guards, Cosmos, change feed, jobs, integrations |
| Frontend | frontend/ — routing, data layer, bundle, SEO, accessibility |
| CI/CD | .github/workflows/ — permissions, pinning, OIDC, deploy safety |
| Edge and ops | edge/, scripts/, tooling/, vps-agent/ |
Each reviewer was given the repository's own standards as the yardstick —
the ADRs for intent, IaC-Repository-Standard and
Naming-Convention for Terraform, the contract tests for
the backend surface — and was instructed to cite file:line for every claim,
to prefer fewer verified findings over broad suspicion, and to record the
areas that came back sound so that silence carries information.
What was excluded¶
Deliberately not re-reported, because they are owner gates already tracked
rather than review findings: T-518 (timers disarmed), T-519 (availability
probe Worker undeployed), ~~T-526 (Telegram webhook still pointing at GCP)~~ — closed 2026-08-28, and it
was already done when this review was written, the
unseeded Key Vault secrets PREVIEW-SIGNING-SECRET and REPLICATE-API-KEY,
the unseeded admin_config documents (default_heroes, social_autopost),
and the absent analytics provider that blocks Blog Machine backlog #4. The
deliberate feature-flag defaults that implement those gates are likewise not
findings.
Evidence standard¶
Three levels appear in this page, and they are distinguished because a review that presents them identically is not a review:
- Verified — re-read against the code during the review session, by a second reader, after the finding was written. Every Critical finding and the three data-durability High findings carry this.
- Reported — the reviewing agent cited
file:lineand the anchor resolves, but no second reader re-derived it. Most Medium and Low findings carry this. - Verify — the claim could not be settled from the repository. Exactly one finding is in this state (T-705's live GitHub environment configuration) and it is marked in place rather than asserted.
Cross-cutting observations¶
Three patterns appear in more than one layer, and are worth more than the sum of the individual findings.
A control exists but does not bind. The production GitHub environment
(T-705) records who deployed without gating whether they may. Confirm-Plan
and ShouldProcess (T-702) both present as confirmation gates and both
self-approve. prevent_destroy (T-708) guards the Cosmos account but not the
data. The role field on job types (T-701) exists, defaults to editor, and
is declared by none of the eight. In each case a reader of the code would
reasonably conclude the control is in force.
A decision's premise expired without the decision being revisited. ADR 0018 accepted LRS storage while Firebase held a second copy; ADR 0023 removed that copy (T-706). The Cosmos containers were guarded as "empty as of 2026-08-20" and now hold roughly 70k documents (T-708). Both were correct when written.
A capability was built and then bypassed. triggers/fetch-image.js has a
complete SSRF guard that two other fetch paths do not use (T-734). Four
call sites use fetchWithTimeout-shaped patterns and five do not (T-712).
fetchPublicContentList accepts provider and type and no caller passes
either (T-716). lib/contentModel.js exists to normalise content fields and
four hooks reimplement it instead (T-738).
1. Azure platform¶
T-706 — Media storage is a single LRS copy (High, verified)¶
Status (2026-08-28): FIXED — moved to RA-GRS, not ZRS. Two reasons: the risk is account and regional loss, which zone redundancy does not cover, and LRS→ZRS is not Terraform-expressible (Azure requires a customer-initiated conversion). The TFC plan succeeded with
prevent_destroyin force, which proves it is an in-place update rather than a replacement.
infra/main.tf:434 · docs/decisions/0018-as-built-plan-v02.md:56-57,73-74 ·
docs/decisions/0023-migration-estate-retirement.md:79-81
ADR 0018 accepted LRS explicitly "while the Firebase source retains the
authoritative copy," recording a revisit trigger of "when Firebase
decommission removes the second copy, or when media becomes irreproducible."
ADR 0023 closed the reverse path and made production authoritative. Every
blob written since the 2026-08-21 cutover — CMS uploads, generated Listen &
Learn audio, AI cover images — therefore exists in exactly one copy in one
region. Blob versioning and soft delete (main.tf:454-502) protect against
overwrite and deletion, not against account or regional loss. The
accepted-risk predicate has expired and the risk is consequently no longer
accepted; it is merely unreviewed.
Recommendation. Convert the content account to ZRS or RA-GRS — an in-place replication change, small at this media volume — or add object replication to a second-region account. Record it as ADR 0018's revisit trigger firing, in a superseding ADR, so the next reader sees a decision rather than a drift.
T-707 — Cosmos recovery is a 7-day window with no out-of-account copy (High, verified)¶
Status (2026-08-28): PARTLY FIXED; remainder on issue #231. Backup tier 7 → 30 days. The out-of-account copy cannot be closed by any account setting, so it belongs with the recovery objectives; it is also gated behind T-518.
infra/main.tf:284-287,203-217 · docs/decisions/0023-migration-estate-retirement.md:79-81
The only restore path for all production website data is 7-day point-in-time
restore whose backup storage is co-located with a single-region serverless
account. Three failure classes are unrecoverable: corruption discovered after
seven days — likely, because the platform runs cleanup timers that delete and
rewrite documents, so discovery is slow; deletion of the account itself
(prevent_destroy is a Terraform-side guard, and it has been lifted once
already); and a Central US regional failure. Serverless is single-region for
life and the conversion is irreversible, so the geography cannot be widened
later without a migration. ADR 0018's T-504 debt row asked for "periodic
backup"; what closed it was continuous backup, which is a different control —
it shortens recovery time, it does not add a second copy.
Recommendation. Add a scheduled export of every container to blob storage
on a geo-redundant account — a few MB per day at this scale — or at minimum
move to Continuous30Days and document the residual out-of-account gap
explicitly.
T-709 — One action group, one receiver, delivery never observed (High, verified)¶
Status (2026-08-28): CODE HALF FIXED; owner action open. An optional SMS receiver is available via a
dynamicblock, so an unset variable leaves the action group byte-identical. Delivery has still never been observed —az monitor action-group test-notificationsremains owner-run.Status (2026-09-01): CLOSED. Both channels have now been observed delivering: the sample budget email arrived on 2026-08-30 and the SMS the same evening, via
az monitor action-group test-notifications createfrom the CLI — the portal's test button reported status Unknown twice and delivered nothing. The full command shape and the evidence standard are recorded in TODO.md under "Live confirmation still requiring an authorized operator".
infra/observability.tf:30-47,157-189
Every alert rule routes through a single action group; Azure alert rules cannot carry a direct email, so there is no second path. That group lives in the Management subscription and is reached across a subscription boundary whose delivery is proven only to be accepted by ARM, and its terminus is one mailbox with no SMS or push fallback. The file's own comment states the consequence: if the reference is accepted and silently inert, the estate has alert rules that exist, evaluate, and page nobody — "strictly WORSE than the visible emptiness" the alerting fabric replaced. The post-apply delivery test that would settle it is the load-bearing step, and no record of it having run exists in the repository.
Recommendation. Run az monitor action-group test-notifications and
record the result in the Deployment runbook §4. Add a
second receiver — SMS or the Azure mobile app receiver, both free. If the
cross-subscription hop proves inert, build the fallback ADR 0022 already
names: a second action group in the application subscription.
T-718 — Cosmos firewall admits every Azure datacenter IP (Medium, reported)¶
infra/main.tf:252-269 · infra/variables.tf:315-318
The 0.0.0.0 sentinel is enabled by default so heal-computed-properties can
reach Cosmos from GitHub-hosted runners, which have no stable egress IPs. Its
effect is to admit any workload in any Azure tenant at the network layer,
leaving AAD as the only control — which is a real control, since local auth is
disabled, but it is one layer where the design intends two. The finding is the
inconsistency rather than the exposure: T-503 solved the identical
"runners have no stable IPs" problem on the Functions host storage account
with an add-IP / work / remove-IP window, so the pattern, the identity and the
role model already exist in this repository.
Recommendation. Apply the T-503 per-run window pattern to the Cosmos
firewall in the healer workflow, then set cosmos_allow_azure_datacenter_ips
to false.
T-719 — The 5xx and latency alerts stop evaluating at the workspace cap (Medium, reported)¶
infra/observability.tf:329-341,78-99 · infra/main.tf:80-84,110-112
Flex Consumption publishes no HTTP metrics, so function_http_5xx and
function_response_time had to be written as log rules — which re-creates,
for the two most important workload signals, the silent-under-cap failure mode
the alerting fabric was designed to eliminate. The workspace was found at its
cap before pruning, and the post-prune daily volume was never confirmed
against an uncapped day, so the current margin is an estimate. The whole chain
now depends on logs_daily_cap firing at 80% and a human acting within hours.
Recommendation. Confirm the volume on an uncapped day and record the
measured GB/day. If headroom is under roughly 2x, pull the documented lever —
host.json log level, or moving AppTraces (about 38% of the cap) to the Basic
table plan — rather than waiting for the 80% alert to become routine.
T-720 — Key Vault reference failures are silent and indistinguishable (Medium, reported)¶
Status (2026-08-28): FIXED —
/api/healthreports a COUNT of unresolved@Microsoft.KeyVault(…)references (a count, not names: the endpoint is anonymous and T-402 already ruled out an unauthenticated inventory).secrets-health.test.jsasserts agreement withreadKeyagainst the real function, since a disagreement there would report a healthy estate while the app behaves as though the key is absent. The alert on it needs an apply — owner.
infra/main.tf:1174-1260,665-673,1699-1703
More than twenty app settings are @Microsoft.KeyVault(...) references. An
unresolved reference arrives at the application as the literal string, and
readKey() treats that as "no key configured" — which is deliberate and
convenient while secrets are still being seeded. The cost is that four
distinct failures become one indistinguishable symptom: unseeded, RBAC
revoked, network denied by an inert service endpoint, and rotated-then-broken
all present as a feature quietly turning itself off, in production,
indefinitely. Application Insights sees no exception, because the code path is
a clean fallback. The Terraform comments document this trap three times; no
signal anywhere observes it.
Recommendation. Have /api/health — already the availability probe's
target — report the count of app settings still holding the literal
@Microsoft.KeyVault prefix against the expected-seeded list, and add a
scheduled-query rule on it. One cheap signal converts four silent failure
classes into one visible one.
T-721 — Telemetry costs five times the workload it observes (Medium, reported)¶
docs/architecture/cost-analysis.md:74-84 · infra/main.tf:138-144,128-133
Neither line is over budget, but together they are roughly 80% of predictable Azure spend on a platform that documents cost discipline to the cent. Telemetry runs about USD 17-21 a month against an application-subscription workload of roughly USD 4, and its documented levers — host log level, the Basic table plan for AppTraces — are unexercised. Separately, the Static Web App Standard tier (about USD 9/month) is justified in-file by custom domain plus SSL, SPA routing and 100 GB bandwidth, all of which the Free tier also provides; the genuinely Standard-only features are the SLA and PR staging environments, which the rationale does not mention.
Recommendation. After the T-719 measurement, drop the host trace level or move AppTraces to the Basic plan. Re-justify SWA Standard against the two features that actually require it, and downgrade if neither is load-bearing.
T-748 — The Terraform principal holds unusable Key Vault Secrets Officer (Low, reported)¶
Status (2026-08-28): FIXED — removed. Verified
data.azurerm_client_configis still used for the vault tenant_id, so nothing is orphaned.
infra/main.tf:1728-1732,1679-1687
HCP Terraform's runners are neither in the VNet nor a trusted Azure service,
so the assignment cannot write from a TFC run today — the grant's current
effect is nil. That is not the same as harmless: whenever admin_ip_rules
opens a seeding window, a shared remote execution environment gains live write
access to every production secret alongside the named human. The repository's
own doctrine (oidc.tf:241-251, "deploys do not read secrets") argues against
it, and because secret values are deliberately never managed by Terraform,
the grant has no consumer to lose.
Recommendation. Delete azurerm_role_assignment.terraform_kv_secrets; the
admin_object_ids window already covers seeding.
T-749 — SCM remains default-Allow (Low, reported, overlaps T-520)¶
infra/main.tf:1055-1077 · infra/variables.tf:886-889
The credential half of this is already closed — basic auth is off on both SCM
and FTP — so exposure is Entra-token-gated, and the per-run window in
deploy-functions.yml exists precisely to make Deny survivable. The finding
is only that the flip has a stated precondition (an observed working deploy
through the window) that deploys have presumably satisfied several times since
2026-08-24.
Recommendation. Confirm one post-window deploy succeeded, then set
functions_scm_lock_enabled = true. The design work is already done.
2. Terraform IaC¶
T-708 — The Cosmos database and containers carry no prevent_destroy (High, verified)¶
Status (2026-08-28): FIXED —
prevent_destroynow covers the database, the containerfor_eachandleases; the stale "containers are empty" comment is corrected to ~70k documents.
infra/main.tf:305,365-412,1886-1897 · guards present at 298,516,807,1710 ·
docs/standards/iac-repository-standard.md ("Stateful resources | lifecycle { prevent_destroy = true }")
Verified by enumeration: the guard covers the Cosmos account, both storage
accounts and the Key Vault, and nothing else. prevent_destroy on a parent
does not protect its children — a container destroy or replace plans and
applies cleanly. Three properties compound it: the containers are generated
from cosmos-containers.json rather than hand-written, partition keys are
immutable so any key change is a destroy-and-create, and the in-file comment
asserting "every container here is empty as of 2026-08-20" is now stale
against roughly 70k production documents. A regenerated spec that renames a
container or alters a key currently produces a data-destroying plan whose only
gate is a human reading the plan carefully — and T-724 explains why plans here
are read by pattern rather than in full.
Recommendation. Add lifecycle { prevent_destroy = true } to the
database, to the container for_each resource (it applies to every instance),
and to leases. Deliberate drops then become the same reviewed two-step the
account already imposes. Refresh the stale comment in the same change.
T-722 — The swa_token output contradicts its own file header (Medium, verified)¶
Status (2026-08-28): RESOLVED as a recorded exception. The header no longer contradicts itself. The output is kept because the token is in state via the resource attribute regardless, so deleting it would hide rather than retire it; it is now in TODO.md accepted risks, and
deploy-azure-frontend.ymlisolates it in a job that installs nothing. Retiring it is T-727, owner-gated.
infra/outputs.tf:5-7 vs 18-22
The header states that sensitive key and connection-string outputs are
intentionally omitted because all runtime access uses managed identity. Eleven
lines below it, output "swa_token" exposes the Static Web App API key. It is
marked sensitive, so it is not printed casually, but it is surfaced on the
TFC Outputs tab to anyone with state read, and it is consumed by GitHub
Actions as exactly the kind of static credential the IaC standard's Principle
2 prohibits. The token is in state via the resource attribute regardless; the
output is what makes it convenient.
Recommendation. If the SWA deploy can move to an Entra token from the existing OIDC identity, delete the output and the CI secret together (see T-727). If the token is genuinely still required, keep it but record it as an accepted exception the way purge protection was, and fix the header sentence so the file stops contradicting itself.
T-723 — Two secrets-in-state surfaces (Medium, reported)¶
Status (2026-08-28): FIXED, better than recommended.
cloudflare_origin_secretis recorded in accepted risks with its rotation consequence. For the azapi export, the invariant it depends on is now ENFORCED rather than the symptom hidden:app-settings-secrets.test.jsfails when a secret-shaped setting is not a Key Vault reference, reporting the name only — printing the value would put a credential in a CI log. Marking the export sensitive would not have stopped a credential being there.
infra/main.tf:1454-1465,1467-1495,2029-2035 · infra/variables.tf:834-839
The IaC standard says values never transit Terraform state. Two places
qualify. The azapi_resource_action that reads app settings back exports
["properties"] — the entire live settings map, not just what HCL declares —
into state, unredacted and unmarked. It is safe only while every
secret-shaped setting remains a Key Vault reference; the first setting written
out-of-band with a literal value lands in state and in TFC plan JSON.
Separately, cloudflare_origin_secret is a real shared-secret value in state,
which is unavoidable because Terraform configures the Cloudflare end — but it
is an unrecorded exception.
Recommendation. Mark the azapi output sensitive where surfaced and comment the "Key-Vault-references-only" invariant it depends on. Record the origin-secret exposure, with its rotation consequence, in TODO.md's accepted risks.
T-724 — The permanent plan diff is asserted only in a comment (Medium, verified)¶
Status (2026-08-28): FIXED.
scripts/assert-expected-plan.mjscompares a plan against the three azapi ADDRESSES and one attribute, catches a destroy hiding beside the expected three, catches a second setting changing on the resource that is legitimately expected to change, and fails when an expected change STOPS appearing (a strip that is not running is how AzureWebJobsStorage returns). Its test pins EXPECTED againstmain.tfboth ways. Not in CI: the plan lives in HCP Terraform andiac-validate.ymlhas no token — owner.
infra/main.tf:1379-1407,1462-1464,1492-1494,1551-1553 ·
docs/decisions/0018-as-built-plan-v02.md:71-72
The read-then-strip design is a correct workaround for azurerm issue #29149 and is thoroughly documented. Its cost is that a clean plan no longer exists: every plan reads "1 to change, 3 to add, 3 to destroy", and the comment says that a plan reporting exactly that "and nothing else means NO DRIFT". ADR 0018's convergence proof was an empty plan; operators are now trained to approve a specific non-empty shape by pattern-matching it. Real drift hiding inside or beside the expected trio is precisely what pattern-approval misses — and T-708 is the finding that makes the consequence data loss rather than inconvenience.
Recommendation. Encode the assertion: a CI step over the TFC plan JSON that fails when the change set differs from the three known azapi addresses plus the one known attribute. Keep the existing "delete when #29149 closes" exit note, and add a checklist item to re-test the issue on every azurerm minor upgrade.
T-725 — Version constraints understate the real floor and lack a ceiling (Medium, reported)¶
Status (2026-08-28): FIXED —
~> 1.5and~> 4.52.
infra/providers.tf:6,23-27 · .terraform.lock.hcl (cloudflare 4.52.8)
cloudflare_record.content exists only from provider 4.52, but the constraint
is ~> 4.0, which admits 4.0 through 4.51 — versions that fail validate on
that attribute. The lock file is currently the only thing keeping the
constraint honest, so a workspace or CI re-resolve without it breaks
confusingly. Separately required_version = ">= 1.5" has no upper bound, so a
future Terraform 2.x is silently permitted, which the standard's pinning rule
does not intend. Worth noting alongside: Cloudflare v4 is superseded by v5,
which renames cloudflare_record to cloudflare_dns_record, so an upgrade
ADR is eventually due.
Recommendation. Tighten to ~> 4.52 and ~> 1.5 (or >= 1.5, < 2.0).
Track the v5 migration separately.
T-750 — CORS origins hardcoded where the sibling block derives them (Low, reported)¶
Status (2026-08-28): WILL NOT FIX — the finding is wrong, and acting on it broke CI.
cors-platform-origins.test.jsreads that block AS TEXT and compares the literal origins againstlib/auth/cors.js, deliberately reading text rather than Terraform state so it can fail on a checkout with no Azure credentials. Interpolations defeat it twice:${var.domain}is not a string it can compare, and a comment betweencors {andallowed_originsbreaks its block regex outright — the worse half, because the guard then passes while checking nothing. The drift it prevents is the 2026-08-23 outage where the portal authenticated and every API call failed with "Failed to fetch". The asymmetry with the storage account's block is therefore justified: only this list has a text-level guard. Reverted, with the two specific don'ts recorded in place.
infra/main.tf:993-1000 vs 483-487
The function app's platform cors block hardcodes both apex origins and a
literal Static Web App hostname, while the storage account's CORS block five
hundred lines earlier correctly derives from var.domain, and the SWA
hostname is available as azurerm_static_web_app.hcw.default_host_name. A
domain change therefore updates one CORS surface and not the other, and a
recreated SWA silently invalidates the literal.
Recommendation. Build the list with concat from var.domain and the
resource attribute. The change is value-identical today, so a no-op plan
proves it correct.
T-751 — The timer catalogue is maintained twice by hand (Low, reported)¶
Status (2026-08-28): FIXED — guarded by a text-reading test in the same shape as the CORS guard, and mutation-tested: removing one name from the validation produces the intended failure naming that timer. Raising
required_versionto reference the local inside the validation was rejected as the alternative — it would exclude Terraform 1.5–1.8 for a lint.
infra/main.tf:864-890 vs infra/variables.tf:1008-1023
The eighteen timer flag suffixes appear in both the catalogue local and the
enabled_timers validation list. Adding a timer to the catalogue without
updating the validation makes it impossible to arm, because validation rejects
the name — a failure mode adjacent to the typo class the validation exists to
prevent. route-inventory.test.js covers code against catalogue, not
catalogue against validation.
Recommendation. Raising required_version (see T-725) to >= 1.9 lets
the validation reference local.timer_catalogue directly, collapsing the
pair. Until then, a CI check or a paired comment.
T-752 — Tag contract values diverge from their source variables (Low, reported)¶
Status (2026-08-28): PARTLY FIXED.
environmentnow derives fromvar.environment.workloadis deliberately not reconciled: the live estate is taggedhybridcloudworkswhile names carrysite, and changing it rewrites the tag on every resource — an owner-visible decision, not a side effect of a Low finding.
infra/variables.tf:937-949,158-162,188-192
tags.environment is the literal "prod", independent of var.environment,
so a non-prod deployment of this root tags every resource prod unless the
operator overrides the whole map. tags.workload is "hybridcloudworks"
while every resource name carries the workload token site — two answers to
"which workload is this" in one estate, which is what a tag contract exists to
prevent.
Recommendation. Build the applied map in a local:
merge(var.tags, { environment = var.environment, workload = var.workload_name }),
keeping var.tags for org-stable keys. Verify against live tag values first —
if the live tag really is hybridcloudworks, changing it is harmless but wide.
T-753 — Variable names exceed the standard's two-word rule (Low, reported)¶
Status (2026-08-28): RESOLVED by amending the standard, which is now marked as the loser. Posture switches are counted by parts, not words, with each half allowed to be compound when the resource genuinely is. The rename was rejected on cost with the reason recorded: every one of these defaults to the SAFE value, so a variable renamed in code but not in the workspace would quietly disarm the estate.
infra/variables.tf:359,370,390,550,574,628,315 among others
The IaC standard caps variable names at two words, three for genuine collisions, and states the rule applies to Terraform variables. A large share of recent additions run four or five words. Most are already set in the workspace, so by the standard's own buckets they are "coordinated — report, never rename silently". This is that report.
Recommendation. Either run the coordinated rename, or amend the standard to carve out compound gate and rollback switches. Today code and standard disagree and neither is marked as the loser.
T-754 — main.tf is a 2,037-line six-concern file (Low, reported)¶
infra/main.tf · docs/decisions/0020-native-terraform-root-module.md
ADR 0020's revisit triggers — a second repository or environment, ALZ
module-level policy, a major azurerm migration — have not fired, so the flat
native root remains the right shape and is not a finding. The residue is:
the repository already splits hub.tf, oidc.tf and observability.tf by
concern, and main.tf holds everything else — resource groups, Cosmos, two
storage accounts, networking, the function app pair, Key Vault, budgets and
DNS.
Recommendation. Split into cosmos.tf, storage.tf, network.tf,
function-app.tf, keyvault.tf, budget.tf and dns.tf. Resource addresses
are unchanged, so this is state-safe by construction and needs no moved
blocks; do it in a PR whose plan shows the T-724 trio and nothing else.
3. Backend¶
T-701 — An editor can publish live, bypassing the publisher gate (Critical, verified)¶
Status (2026-08-28): FIXED —
registerJobTypenow requires an explicit role; all nine types declare one;publish-contentispublisher; escalation compares hierarchy level.jobs.roles.test.jspins both properties. The new validation immediately caught a ninth registration this review had missed (the built-innoop).
functions/src/lib/jobs.js:106,202,219-222 ·
functions/src/functions/publish-jobs.js:39-50 ·
functions/src/lib/cms/publish.js:467
POST /api/publishContent requires the publisher role. The publish-content
job type registers without a role, so it takes registerJobType's 'editor'
default, and enqueueJob escalates only when spec.role !== 'editor' —
so for this type it never escalates. The worker then calls
processPublishContent(…, markLive: true) directly, and that function is
guard-free by design because the only role check lives in the HTTP wrapper.
An editor-level token posting {"type":"publish-content","payload":{"contentId":"…"}}
to enqueueJob publishes content live.
Verification widened the finding: none of the eight registered job types
declares a role — forge-article, forge-from-url, voice-calibration,
generate-weekly-digest, batch-inspect, generate-listen-and-learn,
publish-content and fetch-rss-feeds all inherit editor. Most are
plausibly correct at that level, but nothing establishes that; the jobs
platform is a second door onto every pipeline it wraps and no mechanism
requires that door to match the first one's lock.
Recommendation. Make role mandatory in registerJobType — remove the
default so a new job type cannot silently inherit editor — and declare
role: 'publisher' on publish-content. Add a test asserting each job type's
role is at least the role of the HTTP route that performs the same action.
T-710 — Jobs stranded in running are never reaped (High, verified)¶
Status (2026-08-28): FIXED — the sweeper reaps jobs abandoned in
runningusing each type's owntimeoutMsplus a grace margin, and firesonComplete. It writes a terminal status rather than re-enqueuing: a dead worker may already have completed real side effects.
functions/src/lib/jobs.js:331-335,458-461 ·
functions/src/functions/jobs-sweeper.js:4-6,34-39
The sweeper re-drives jobs that have sat queued too long; verified against
its own header, nothing reaps running. When a worker dies mid-run — host
restart, scale-in, deploy, or the platform timeout beating a job's own
28-minute budget — the document stays running permanently: redelivery sees
status !== 'queued' and returns skipped, the sweeper ignores it, and
getJob reports running indefinitely to a client that will poll forever.
The onComplete failure hook never fires either, so the Telegram failure
notification that exists precisely for "a failed approval from the phone comes
back to the phone" is also lost. The queued-job gap was closed deliberately;
this is the same failure one state later.
Recommendation. Add a second sweeper query for
status = 'running' AND startedAt < now - (maxTimeoutMs + margin),
transitioning those to timeout with an explicit error and invoking the
type's onComplete. Separately, confirm the platform timeout exceeds 28
minutes — host.json sets no functionTimeout, so the job budget is
currently racing the platform default.
T-711 — buildSnapshot fans out up to 2,000 concurrent point reads (High, verified)¶
Status (2026-08-28): FIXED — deduplicated (one document carries up to four images) and batched with
ARRAY_CONTAINS. The existing fixture proves the count is unchanged.
functions/src/lib/ops-health.js:154-157,190-198 ·
functions/src/lib/telegram/bot.js:270,400 ·
functions/src/functions/telegram-http.js:38-41
buildSnapshot reads SELECT TOP 2000 from generated_content_images, then
Promise.alls a readDoc per row — an unthrottled fan-out of up to two
thousand point reads per call, to count orphans. That table grows by up to
four rows per AI cover run, so the bound is reachable rather than theoretical.
Every /status, /queue, /alerts, /digest and /ai triggers it, as
does every free-form Telegram message, and so does the ops-health route.
Against the roughly 5,000 RU/s budget the public-reads header cites, a single
snapshot can 429 the anonymous list endpoints — that is, a Telegram message
can degrade the public website.
Recommendation. Replace the per-image probe with a single
SELECT VALUE COUNT(1) over a maintained orphaned flag, or compute the
count in a timer and store it. If the probe must stay, cap concurrency to
about twenty and cache the snapshot for 30-60 seconds — the Telegram path
currently re-derives it per message.
T-712 — External calls in the change-feed path have no timeout (High, verified)¶
Status (2026-08-28): FIXED — one shared
fetchWithTimeout, lifted out ofscrape.js. The Replicate poll also gains a wall-clock deadline, since an iteration count does not bound elapsed time when each iteration both sleeps and requests.
functions/src/lib/triggers/ai-cover.js:151,171 ·
functions/src/lib/timers/publer-sync.js:124 ·
functions/src/lib/notify.js:80 · functions/src/lib/telegram/bot.js:470
Replicate (both the POST and its 60-iteration poll), Publer and Telegram
sendMessage are all called through bare fetchImpl with no AbortController
or signal. Node's fetch has no default timeout. All three are reached from
change-feed handlers, where a hung connection blocks the invocation, the lease
is never checkpointed, and every subsequent change on that container queues
behind it — a single unresponsive third party stalls the pipeline rather than
failing one document. This is an inconsistency rather than an unknown: the
correct pattern already exists at ai/router.js:301-310,
content/scrape.js:41-50, triggers/fetch-image.js:75-83 and
timers/link-check.js:33-40.
Recommendation. Route every outbound call through one fetchWithTimeout
helper — scrape.js's is already general enough — with an explicit
per-integration budget, and give the Replicate poll a wall-clock deadline
rather than only an iteration count.
T-730 — A Telegram send failure becomes a job-duplicating retry storm (Medium, reported)¶
Status (2026-08-28): FIXED — the send is inside the guard, and the route wraps
handleUpdateas well, so nothing past the secret check can return non-200.
functions/src/lib/telegram/bot.js:436-447 ·
functions/src/functions/telegram-http.js:110-112
handleUpdate's try/catch wraps command dispatch, but await send(reply) sits
outside it. A network-level rejection from the sender — which has no timeout
either, per T-712 — propagates out of handleUpdate, out of the webhook
handler, and the host returns 500. The route's own header states the invariant
this breaks: always 200 once the secret is valid, because "a 500 on a bad
command turns one broken message into a retry storm that re-runs the command."
For /forge, /approve, /rss and /inspect each Telegram retry re-runs the
enqueue, so a transient outage produces duplicate publish and forge jobs.
handleUpdate's own JSDoc promises it never throws.
Recommendation. Move the send inside the try, or give it its own catch
returning {handled: true, sendFailed: true}. Belt and braces: wrap
bot.handleUpdate at the route so nothing past the secret check can return
other than 200.
T-731 — The change feed has no per-invocation work budget (Medium, reported)¶
Status (2026-08-28): FIXED, not as recommended. The content feed drops to 8 items and the handler carries a 10-minute budget (inside
DEFAULT_CLAIM_TIMEOUT_MS, which is the bound that matters). The review said "return early"; that would advance the lease past documents never looked at, and the feed only redelivers on a subsequent write, so their triggers would never fire again. It throws instead. Never checked before the first document, or one heavier than the budget would redeliver forever.
functions/src/functions/change-feed.js:86-93 ·
functions/src/lib/triggers/handlers.js:128-166
maxItemsPerInvocation is 50, and a single content document can require an
AI cover (up to four Replicate generations, each with Prefer: wait=60 plus
up to 120 seconds of polling), an inspection with model calls, a caption
generation and a Publer call. A batch of documents all carrying
altCoverImageTrigger cannot complete inside any plausible function timeout,
and because the lease checkpoints only after the handler returns, a killed
invocation redelivers the whole batch. Rising-edge claims prevent duplicate
spend on completed items, but in-flight items hold a claim for the full
fifteen-minute window, so the feed can livelock rather than fail visibly.
Recommendation. Lower maxItemsPerInvocation for the content feed to
single digits, and add a wall-clock budget to the handler loop that returns
early once a fraction of the timeout is consumed. The remaining documents
redeliver next invocation, which the claims already make safe.
T-732 — The guard test probes only the first verb of each registration (Medium, verified)¶
Status (2026-08-28): FIXED — the property now probes every non-OPTIONS verb. Nothing was actually unguarded, which the widened test confirms.
functions/src/functions/route-inventory.test.js:216 ·
functions/src/lib/auth/http-route.js:274-301
The guard property invokes each registration with options.methods[0] only,
while httpRouteByMethod fans up to three verbs behind one registration — for
example PUT, PATCH and DELETE on cms/config/{collection}/{id}. So only the
first verb is ever guard-checked. Every merged verb was checked by hand during
this review and all call requireRole (15/15 in admin-integrations.js, 8/8
in admin-crud.js, 5/5 in cms/image-prompts.js), so this is a hole in the
safety net rather than a live vulnerability — but this test is explicitly the
replacement for the firestore.rules default-deny catch-all, and that
catch-all had no per-verb blind spot.
Recommendation. Iterate every non-OPTIONS method in options.methods
and assert guardCalls() > 0 for each. Property 4's second test already walks
all verbs and can be reused.
T-733 — One trigger's failure cancels the rest, and the stats update (Medium, reported)¶
Status (2026-08-28): FIXED — each trigger branch is isolated, and
applyTransitionwith it.
functions/src/lib/triggers/handlers.js:128-166
The inspectTrigger branch is individually wrapped; altCoverImageTrigger,
forgeReadyNotifyTrigger and socialCaptionTrigger are not. They rely on each
run() catching internally — but each of those catch blocks ends in a
patchDoc that can itself throw. When it does, the document-level catch takes
over and the remaining triggers and dashboardStats.applyTransition are
skipped for that document — the counter maintenance the adjacent comment
identifies as the thing that must run last. The per-trigger failure semantics
are deliberate and documented; the isolation that would make them per-trigger
is missing.
Recommendation. Wrap each trigger branch in its own try/catch recording
out.<trigger> = 'error', matching the inspect branch, and run
applyTransition in a finally so counters are never collateral damage.
T-734 — Scraped external URLs are fetched with no SSRF guard (Medium, reported)¶
Status (2026-08-28): FIXED —
generateAltTextsnow uses the SSRF-validating fetcher, which also gained the missing size cap (Content-Length honoured before buffering, buffered length re-checked after) for every caller.
functions/src/lib/content/inspect.js:246,309-310
generateAltTexts fetches URLs taken from scraped.images — that is, from
whatever page sourceUrl pointed at — using bare fetchImpl, then base64s the
result into a multimodal model call. There is no protocol check, no
private-range refusal, no timeout and no response-size ceiling before
arrayBuffer(). The repository already contains exactly the right primitive:
triggers/fetch-image.js validates the protocol, refuses localhost, resolves
the host to IPv4, refuses private ranges, and re-checks on every redirect hop.
Neither inspect.js nor content/scrape.js uses it. Editor-arming and the
CONTENTFORGE_ALT_TEXT_ENABLED flag are what keep this Medium.
Recommendation. Call fetchImage from triggers/fetch-image.js in both
places, and add a byte ceiling before buffering.
T-735 — A lost forge_ready notification is lost permanently (Medium, reported)¶
Status (2026-08-28): FIXED — records a numeric attempt counter and two strings, never a boolean, so it cannot re-arm the rising edge. The file header claiming it "writes NOTHING" is corrected rather than left to mislead.
functions/src/lib/triggers/forge-ready-notify.js:102-117
On any not-sent outcome the handler writes nothing, leaving the trigger flag
true and a live claim — deliberately, so that a failure marker cannot re-fire
the feed into a loop. The reasoning holds as far as it goes, but its escape
hatch is "any later write to the document retries", and nothing writes to a
forge_ready document again unless a human acts. So a transient Telegram
failure at that moment strands the draft: the owner is never told it is
staged, and the only evidence is a flag sitting true in Cosmos. T-712 widens
the window, since the send has no timeout.
Recommendation. Stamp a bounded attempt counter and a retry-after
timestamp on the not-sent path — numeric fields, so the flag logic is not
re-armed — and have a sweeper re-drive forge_ready documents whose trigger
is still armed past a threshold.
T-760 — Dead exports that are wrong if anyone uses them (Low, reported)¶
Status (2026-08-28): FIXED — both deleted.
functions/src/lib/cosmos-client.js:571-574,587-614
batchRead calls readDoc without a partition key, which
resolvePartitionKey throws on for the five non-/id containers the module
goes to considerable lengths to protect. watchChangeFeed is a hand-rolled
polling loop that swallows errors to console.error, which is documented
elsewhere in the codebase as not reaching Application Insights. Neither is
referenced anywhere in src/; the real feed is app.cosmosDB in
change-feed.js.
Recommendation. Delete both. If batchRead is wanted later it should take
partition keys.
T-761 — The daily forge budget has one enforcement point and a race (Low, reported)¶
Status (2026-08-28): FIXED.
claimForgeBudgetis a server-side compare-and-increment taken before any model call, after the dedupe check (a duplicate costs no tokens) and before generation (a half-finished run has already spent). Editor forging stays uncapped as allowed, but is now COUNTED — otherwise the ceiling is measured against a number ignoring half the spending.
functions/src/lib/timers/forge-scheduled.js:82-98 ·
functions/src/lib/content/forge.js:263-293 ·
functions/src/functions/forge-jobs.js:54-59
autoForge.dailyLimit is read and compared only in the scheduler.
bumpForgeStats is a best-effort read-modify-write that swallows its own
failures, and the forge-article job accepts up to ten documents per call
with no budget check. Editor-initiated forging being unlimited is a defensible
choice; the finding is that the ledger the scheduler trusts is written by a
path that can silently fail, and remaining is computed once before a
sequential loop, so a concurrent manual forge is never observed. This is the
system's only AI-spend ceiling.
Recommendation. If the cap is meant as a real budget, move the check inside
runForgePipeline using incrementIf against today.forged — the
compare-and-increment primitive already exists and is used correctly for the
submission quota.
4. Frontend¶
T-714 — Pre-rendered HTML is discarded at boot (High, verified)¶
Status (2026-08-28): OPEN — needs an owner decision. The seed mechanism exists but is deliberately never mounted in the browser; switching to
hydrateRootwithout wiring it trades a spinner for hydration mismatches on every page. This needs real-browser verification, not a quiet edit.Status (2026-09-01): CLOSED (#296). The wiring landed:
main.jsxcallshydrateRootwhen the mount point'sdata-prerendered-routestamp matches the live path, seeded from the same element, and client-renders as before when it does not. Verified by five Playwright tests in a real browser, including a node-identity probe; both guards mutation-tested. Record in CHANGELOG.md.
frontend/src/main.jsx:16 · frontend/scripts/prerender.mjs:219-220 ·
frontend/src/App.jsx:275
The build writes 104 real HTML documents, and prerender.mjs:219 states "the
client bundle still hydrates into it." Verified: it does not.
ReactDOM.createRoot(rootElement).render(...) discards the container's
existing children and renders from scratch — only hydrateRoot adopts
server-rendered markup. Because every route is a React.lazy behind a single
<Suspense>, the visible sequence is: the pre-rendered article paints, React
clears the container, a spinner appears, and the content returns once the
route chunk downloads. That is strictly worse than either a pure SPA or true
hydration, and it discards the LCP and CLS benefit the entire pre-render step
exists to produce.
Recommendation. Serialize the prerender seed into the document (for
example window.__HCW_PRERENDER__, read by hooks/prerenderData.js), switch
to hydrateRoot, and preload the route chunk for the rendered path so the
lazy boundary resolves synchronously. If hydration is judged too risky in the
short term, document the pre-render as crawler-only and stop claiming
hydration in prerender.mjs.
T-715 — A 456 kB chart bundle is preloaded on every page (High, verified)¶
Status (2026-08-28): PARTLY FIXED, MEASURED — and the prescribed fix does not work. rolldown places jsx-runtime by its own rules: claiming react in an earlier chunk moved only
scheduler, and removing the manual chart chunk made things worse (shared vendor grew to 651 kB). Splitting the libraries so only a small chunk rides with jsx-runtime does work: 868 kB → 571 kB preloaded. recharts (237 kB) and d3 (60 kB) are now lazy-only; chart.js still rides along. Unusedd3dependency removed.
frontend/vite.config.js:29-39,124-179 · frontend/dist/index.html ·
frontend/package.json:63,66,80
The pickChartsChunk predicate is applied before all other chunking rules,
which places React's jsx-runtime inside vendor-charts — making that
chunk a static dependency of the app entry. Verified in the built output:
dist/index.html and every pre-rendered page carry
<link rel="modulepreload" href="/assets/vendor-charts-*.js"> for 456 kB raw,
and the entry chunk opens by importing from it. The home page therefore ships
chart.js, recharts and d3 to render zero charts. Compounding it: d3 is a
declared dependency that no source file imports (verified: zero matches
across frontend/src), recharts is used in one admin file and chart.js in
one widget — two charting stacks for two components.
Recommendation. Drop d3 from package.json, consolidate on one charting
library, and scope pickChartsChunk to node_modules/ paths — id.includes('d3')
is a loose substring test that matches unrelated paths. Then add a build
assertion that fails if dist/index.html modulepreloads any chunk above about
150 kB; this class of regression is invisible without one.
T-716 — Public list pages download the whole corpus, three times (High, reported)¶
Status (2026-08-28): FIXED — request-layer dedupe keyed on path+query, plus one
PUBLIC_CORPUS_LIMIT. Deliberately NOT pushed server-side: client provider matching includes text inference the server does not perform, so filtering there would silently drop posts (see T-738).
frontend/src/hooks/useBlogData.js:216,231 ·
frontend/src/hooks/useProviderLandingContent.js:160,188 ·
frontend/src/hooks/useFrameworkData.js:190,199 ·
frontend/src/lib/publicApi.js:37-47
fetchPublicContentList accepts type and provider, and the server expands
provider aliases — none of the three call sites passes either. /aws/blog
fetches every published document with bodies included and then discards
non-AWS rows in JavaScript. The three hooks use three distinct cache keys and
two different limits for what is otherwise the same request, and
usePublicData has no shared cache, so a visitor moving /aws → /aws/blog
→ /aws/frameworks triggers three near-identical full-corpus downloads.
Recommendation. Pass { provider, type } server-side, unify the limit,
and give usePublicData a module-level promise cache keyed on the request URL
so concurrent and repeat callers share one in-flight request.
T-717 — A failed fetch renders the previous route's content (High, reported)¶
Status (2026-08-28): FIXED — a failed fetch clears
datainstead of leaving the previous route's article under the new route's canonical, and the wrapper hooks surfaceerrorinstead of hardcoding null.
frontend/src/hooks/usePublicData.js:43-50,79-84 ·
frontend/src/components/templates/BlogDetailTemplate.jsx:63-68 ·
frontend/src/hooks/useBlogData.js:254 · frontend/src/hooks/useFrameworkData.js:214
On a key change the hook deliberately keeps the previous data to avoid an
empty flash. On rejection it sets error and clears loading but leaves
data untouched. The two behaviours combine badly: navigating
/aws/blog/a → /aws/blog/b with a failed request for b renders article
A's title, body and image while the template emits canonical, og:url and
og:title for b — wrong content under a correct-looking URL and correct
metadata. BlogDetailTemplate destructures only {data, loading}, and the
wrapper hooks hardcode error: null, so no error reaches any UI on the blog
or framework paths. There is no retry either: one network blip is terminal.
Recommendation. Clear data when the key changes and the fetch errors,
keeping the carry-over only for the pending state; surface error through the
wrapper hooks; render an error state in the detail templates; add one bounded
retry for network and 5xx failures in publicGet.
T-736 — MSAL is in the static import graph of a public route (Medium, reported)¶
Status (2026-08-28): FIXED and measured. Two static edges, not the one reported:
useAdminAuthANDlib/api.js, the latter inherited by anything importingpostJSON. Both dynamic now. NewsPage static closure 1,060,504 → 816,158 bytes,entraAuthandvendor-msalgone from it. A chunk-graph test holds the line, with a guard-the-guard case.
frontend/src/hooks/useGenerateCuratedImages.js:4,80 →
frontend/src/hooks/useAdminAuth.js:17-18 → frontend/src/lib/entraAuth.js
The header comment on useGenerateCuratedImages states that its role gate
"stops the hook dragging MSAL onto the critical path of a public page." The
runtime gate does work; the module graph does not follow it. Because
useAdminAuth is a static import, the built NewsPage chunk statically
imports entraAuth, which imports vendor-msal — so an anonymous visitor to
/azure/news downloads and executes 236 kB of @azure/msal-browser and runs
onAuthStateChanged. App.jsx:226 and useAuthRedirectLanding.js:43 got this
right with a dynamic import; this path did not.
Recommendation. Move the role check behind a dynamic
import('@/hooks/useAdminAuth'), or split the "am I an editor" read into a
small module that does not pull entraAuth. Add a chunk-graph assertion that
no public page chunk reaches vendor-msal.
T-737 — Eleven public routes are neither pre-rendered nor in the sitemap (Medium, reported)¶
Status (2026-08-28): FIXED. 16 standalone routes pre-rendered; build 104 → 120 documents and sitemap 104 → 120 entries.
routes-are-complete.test.jschecks both directions — a declared route not pre-rendered, and a pre-rendered route that no longer exists (which would publish a 200-status NotFound page).X-Robots-Tag: noindexadded for/admin/*and/preview/*, ordered before the/*.htmlrule since SWA applies the first match.
frontend/scripts/prerender-entry.jsx:26-37,61-72 ·
frontend/scripts/prerender.mjs:245-253 · frontend/dist/sitemap.xml
routes() enumerates only /, /about, /contact and the
/:provider/<section> grid. Confirmed missing from both the sitemap and disk:
/tools/migration, /tools/comparison, /tools/resources, /tools/decisions,
/finops/tools, /finops/focus, /terraform/modules, /terraform/tools,
/github/workflows, /github/tools and /templates/* — all declared in
App.jsx and all indexable. They fall back to app-shell.html, which has a
generic title and no canonical. Separately, seedFor matches only
/blog/([^/]+)$, so architecture-designs/:slug, frameworks/:slug,
coder-corner/:slug, news/:slug and code/:slug — the templates with the
richest metadata — are never pre-rendered. Related: staticwebapp.config.json
sets no X-Robots-Tag for /admin/* or /preview/*, and a robots.txt
Disallow prevents crawling but not URL-only indexing; the Helmet noindex
is client-side only and app-shell.html carries none.
Recommendation. Derive the static-route list from the same source App.jsx
uses, or add a validation asserting every non-parameterised public route
appears in routes(). Extend seedFor to the other detail sections, and add
X-Robots-Tag: noindex route headers for /admin/* and /preview/*.
T-738 — Provider normalization is reimplemented four times and has diverged (Medium, verified)¶
Status (2026-08-28): FIXED, and the finding under-stated itself. One table-driven normalizer in
lib/providers.js, walked by its own test so a new provider is covered when added. Separately:contentModel.normalizeContentProvidermatched EXACT keys, so "Microsoft Azure" becamemicrosoftazureand "AWS Lambda" becameawslambda— and it feedsgetContentPublicPath, so it built public URLs no route serves, for the normal case rather than an exotic one.
frontend/src/hooks/useBlogData.js:13-53 ·
frontend/src/hooks/useProviderLandingContent.js:24-57 ·
frontend/src/lib/contentModel.js:53-68 · frontend/src/lib/blogUtils.js:8-19
Four independent provider-canonicalisation implementations with different
alias tables. The divergence is live, not hypothetical:
useProviderLandingContent handles vmware, ansible, broadcom and
redhat; useBlogData does not. So a VMware or Ansible document without an
explicit provider field appears on the landing page and vanishes from
/vmware/blog. The same shape repeats for field aliasing — Title || title,
the five-way published-date coalesce — reimplemented in BlogDetailTemplate,
both hooks, useEditorState and a dozen admin files, even though
blogUtils.normalizeContentFields exists for exactly this.
Recommendation. Make lib/contentModel.js the single normalizer for
provider canonicalisation, field aliasing and dates; have every hook and
template consume it; delete the local copies. A table-driven test over the
alias set is the cheap guard against re-divergence.
T-739 — N+1 fetch on the public news grid (Medium, reported)¶
Status (2026-08-28): FIXED. New anonymous
GET public/curated-images?ids=…, one ARRAY_CONTAINS query for the grid, bounded at 50 ids so it cannot become a point-read amplifier. Seven disclosure cases are run through BOTH handlers and compared, so a rule changed in one and not the other fails rather than leaking. A failed batch yieldsundefinedper id, not null, so the hook falls back per-article instead of reading one bad request as "no article has a cover".
frontend/src/hooks/useGenerateCuratedImages.js:209-216,120
generateImagesForArticles maps over the article list and issues one
GET public/curated-image/{id} per article. For a twelve-card news grid that
is twelve extra round trips before any cover image appears, on a route that
has already fetched the feed. There is no batch endpoint and no per-id memo,
so a remount repeats all of them.
Recommendation. Add a batched public/curated-images?ids=… read, or return
the image URL on the feed document itself — fetchPublicFeed already folds two
queries into one round trip — and memoize resolved ids across mounts.
T-740 — Route transitions are silent and unfocused (Medium, reported)¶
Status (2026-08-28): FIXED. Focus moves to
#main-content(preventScroll, or it fights the scroll reset), a polite live region announces the new title,PageLoadergainsrole="status"and a name,Skeletongains a dark token. Hash links are left alone entirely —#sectionmeans "go here", and stealing focus would undo it.
frontend/src/components/shared/ScrollToTop.jsx:7-9 ·
frontend/src/App.jsx:174-178,273 ·
frontend/src/components/performance/Skeleton.tsx:52
Route changes scroll to top but never move focus. <main id="main-content"
tabIndex={-1}> exists and is the obvious target but is never focused, and
there is no aria-live route announcer — so screen-reader and keyboard users
remain parked on the previous page's link with no announcement after every
navigation. The PageLoader shown on every lazy route is a bare spinning
div with no role="status" and no accessible name, so the transition is
silent as well as unfocused. Separately, Skeleton hardcodes bg-slate-200
with no dark-mode token, so dark-theme skeletons render as near-white blocks.
The header itself is in good shape — correct aria-expanded, aria-controls,
role="menu" and Escape handling.
Recommendation. Focus #main-content in ScrollToTop's effect (guarding
hash links), add a visually-hidden aria-live="polite" region announcing the
new document title, give PageLoader role="status" and a label, and
tokenize the Skeleton background.
T-762 — Duplicate route declarations leave dead branches (Low, reported)¶
Status (2026-08-28): FIXED. Three shadowed static routes removed; the
/:providerblock already served all three, and does it insideProviderLayout. The dispatchers readslugfromuseParamsinstead of sniffing the pathname. The first version of the guard MISSED the bug — it compared absolute paths to each other, but the shape is an absolute route shadowing a RELATIVE child — and now detects that; all four shadowing shapes fail.
frontend/src/App.jsx:227-228,282-311,317-333,510-535 ·
frontend/src/context/ProviderContext.jsx:169-183
/terraform/code, /github/code and /finops/architecture-designs are
declared both inside the /:provider block and as static top-level routes.
Static wins, so the terraform and github list branches in
ProviderCodeDispatcher are unreachable while their :slug detail branches
still run — one URL family served by two code paths. More consequentially,
the top-level declarations sit outside ProviderLayout, so useProvider()
is null on /finops/tools, /terraform/modules, /github/workflows and
their siblings while it is populated on adjacent routes; App.jsx:227-228
compensates with a second, independent derivation of the provider from
location.pathname. The dispatchers also infer list-versus-detail by sniffing
pathname.split('/').pop() when the route already supplies :slug via
useParams.
Recommendation. Nest the provider-specific static routes inside the
/:provider element so context is uniform, delete the duplicated top-level
declarations, and switch the dispatchers to useParams().slug. Related:
pages/azure/EducationPage.jsx is 1,544 lines compiling to a 141 kB chunk of
largely inline catalogue data, which belongs in public/data/*.json.
5. CI/CD¶
T-705 — The production environment gates nothing, and dispatch accepts any ref (Critical, verified + one verify item)¶
Status (2026-08-28): PARTLY FIXED; owner action open. Both deploy workflows now refuse a dispatch from any ref but
main, and Required-Inputs §4.4 no longer asserts a gate whose existence could not be confirmed. The environment protection rules themselves still need configuring.
.github/workflows/deploy-functions.yml:16,35-40 ·
.github/workflows/deploy-azure-frontend.yml:16,24-30 · infra/oidc.tf:153-166
Both deploy workflows are workflow_dispatch-only and bind their job to
environment: production. Because the federated credential's subject is
environment-scoped (repo:…:environment:production), the OIDC token matches
regardless of which ref the dispatch runs against. So the 14-check PR gate
and the 12 required contexts are bypassable by anyone with write access:
dispatch a deploy from an unreviewed branch and it ships to production with
full Azure credentials. The frontend deploy is the same shape, with the SWA
token available to any-ref dispatch.
Both workflow files' own comments state that GitHub auto-creates a missing
environment with no protection rules, so binding to it "records who deployed
without gating whether they may." TODO.md:300 contradicts this,
recording the environment as VERIFIED and "Gates production deploys". The
GitHub environments API is not reachable through the review session's proxy,
so which is true is a verify item — but the two records cannot both be
right, and that alone needs resolving.
Recommendation. Configure required reviewers and a main-only
deployment-branch restriction on the production environment; optionally add
a guard step failing when github.ref != 'refs/heads/main'. Then reconcile
Required-Inputs §4.4 with the configuration that actually exists.
T-713 — The storage firewall stays open if a deploy dies mid-window (High, reported)¶
Status (2026-08-28): FIXED — the hourly monitor now probes storage default action and leftover
ci-*rules, so an orphaned deploy window pages within the hour.
.github/workflows/deploy-functions.yml:97-101,145,248-264,294-300
The deploy flips the Functions host storage account to
--default-action Allow for the duration of the package upload and trigger
sync. The always() close steps do not run on runner loss, infrastructure
failure, or a force-cancelled job — in those cases the account, which is the
host's secret repository, stays network-open indefinitely with only Entra
data-plane auth remaining. Stale ci-deploy-scm-* and ci-smoke-* allow rules
for a since-recycled runner IP persist the same way. Nothing detects either:
monitor-functions-registered.yml checks function count and app settings but
never firewall posture.
Recommendation. Add defaultAction == Deny and a zero-ci-*-rules
assertion to the hourly monitor workflow, which already authenticates with an
identity holding sufficient rights. An orphaned window then pages within the
hour instead of persisting silently.
T-726 — A scheduled workflow pushes to main past the gate (Medium, reported)¶
Status (2026-08-28): FIXED. Two jobs:
buildholds the Azure identity and nevercontents: write;commitholdscontents: write, installs nothing, and runs only git and node built-ins.buildinstalls with--ignore-scripts. The ruleset bypass itself is owner-gated.
.github/workflows/publish-content-manifest.yml:28-29,60,71-86 ·
.github/workflows/sync-wiki.yml:27
For this scheduled git push to succeed against a main protected by twelve
required contexts, the ruleset must bypass the Actions token — which means
every workflow holding contents: write carries a token that can push
arbitrary commits to main past all checks. Additionally the workflow runs
npm ci in scripts/ while holding both contents: write and
id-token: write (the full deploy identity), which is the single highest
supply-chain concentration in the pipeline. And the manifest content itself —
articles written by humans and by the forge — lands on main and flows into
prerender output with no review.
Recommendation. Commit via a scoped bot or an auto-merging PR, or narrow
the ruleset bypass to a deploy key limited to this path. Run the Cosmos read
with --ignore-scripts, or in a job separate from the one holding
contents: write.
T-727 — The SWA deployment token is the last long-lived credential (Medium, reported)¶
Status (2026-08-28): MITIGATED. The token now lives in a job that installs nothing, so a compromised build dependency cannot reach it. It can still poison that deploy's content — it produced it — but not take the credential and publish again tomorrow, which is the harm named. Retiring it (OIDC, or an environment secret on a protected
production) is owner-gated.
.github/workflows/deploy-azure-frontend.yml:46-57,152
AZURE_STATIC_WEB_APPS_API_TOKEN is the one remaining stored, non-expiring
credential in the pipeline; everything else is federated OIDC. It is used in
the same job that has just built the frontend from npm dependencies, so a
compromised build dependency has it in reach, and exfiltration grants standing
ability to publish arbitrary content to the public site until someone rotates
it manually.
Recommendation. Move to OIDC-based SWA deployment, or store the token as
an environment secret behind the (to-be-protected, per T-705) production
environment and set a rotation cadence. See also T-722.
T-728 — One identity serves monitors and deploys alike (Medium, reported)¶
infra/oidc.tf:41-46,176-202 · monitor-functions-registered.yml:90-94 ·
verify-alert-state.yml:62-66 · heal-computed-properties.yml:71-75
Every workflow authenticates as the same client id, and the branch credential
trusts ref:refs/heads/main. So the read-only monitor and alert-verify jobs —
which need Monitoring Reader and a Website read at most — run as an identity
that also holds Storage Account Contributor, Storage Blob Data Contributor,
Website Contributor and Cosmos data writes. A compromise of any scheduled
workflow's dependency chain yields the full deploy blast radius.
Recommendation. Split into a reader identity for monitor and verify and a deploy identity for the rest, each with its own federated credential.
T-729 — No concurrency control on the frontend deploy, and no rollback anywhere (Medium, reported)¶
Status (2026-08-28): FIXED.
concurrency: swa-deploy, cancel-in-progress false. Rollback is documented with the constraint that makes it non-obvious: you cannot roll back by dispatching an older ref, because the T-705 guard refuses any ref but main. Deliberately not automated — a post-deploy check from a GitHub runner is a datacenter client, which Bot Fight Mode 403s, so it would roll back healthy deploys.
.github/workflows/deploy-azure-frontend.yml:15-30 ·
.github/workflows/deploy-functions.yml:302-345
The frontend workflow declares no concurrency group, so two overlapping
dispatches race the SWA upload with nondeterministic last-writer-wins. The
functions deploy handles concurrency correctly, but neither has a rollback
path: a failed post-deploy smoke test leaves the new, bad package live and
merely marks the run red. Mean time to recovery is therefore "a human notices
a red run and re-dispatches an older ref" — which T-705 shows is not even
branch-restricted.
Recommendation. Add concurrency: {group: swa-deploy, cancel-in-progress: false}
to the frontend workflow. Document and ideally automate rollback: re-run of
the last green run's ref, or retain the previous package URL and re-point on
smoke failure.
T-755 — The Trivy checksum shares an origin with the binary (Low, reported)¶
Status (2026-08-28): FIXED — digest pinned in-repo and verified against the real artifact (
sha256sum -cpassed locally) before committing.
.github/workflows/iac-validate.yml:119-130,159-165
The sha256 manifest is downloaded from the same GitHub release as the tarball it verifies, so an attacker who can re-point the release — exactly the trivy-action incident the workflow's own comment documents — controls both. The comment already recommends the fix.
Recommendation. Embed the tarball's sha256 literal in the workflow beside
TRIVY_VERSION.
T-756 — A dispatch input is interpolated into a shell command (Low, reported)¶
Status (2026-08-28): FIXED — passed through
env:.
.github/workflows/heal-computed-properties.yml:94
node apply-computed-sortdate.mjs --${{ inputs.mode || 'apply' }} splices a
dispatch input into a run: string. Exploitability is near nil — a
type: choice input is validated against its options at dispatch time, and
dispatching requires write access — but it is the only deviation from the
env-var pattern the repository otherwise applies deliberately.
Recommendation. Pass via env: and reference "$MODE", matching
validate-deployed.yml:106-109.
T-757 — Gate coverage gaps (Low, reported)¶
Status (2026-08-28): HALF FIXED, HALF WRONG. The
vps-agenthalf is closed (see T-743). The frontend half is wrong:test:adminis plainvitest runwith no include filter and has been since T-320 closed exactly this — 33 files, 315 tests, all of them. The finding was inferred from the script's NAME. The name was the defect and is gone:testis the CI-correct run,test:watchthe watch mode.
.github/workflows/ci.yml:41,49-50
A broken change to vps-agent/ merges with only npm ci validating it — see
T-743 for why that surface deserves more — and frontend regressions outside
the admin suite are invisible to the gate, which runs test:admin only.
Neither is a vulnerability (PR permissions are read-only); both are accepted
risk worth recording rather than rediscovering.
Recommendation. Add at least a lint or typecheck step for vps-agent, and
widen the frontend test script as the suite stabilises.
T-758 — dependency-review requests write on a fork-facing trigger (Low, reported)¶
Status (2026-08-28): FIXED — both the write scope and the comment option removed.
.github/workflows/dependency-review.yml:21-24,36
On fork pull requests the token is silently downgraded to read, so
comment-summary-in-pr: always degrades to a warning and posts nothing —
inconsistent gate output rather than a vulnerability. The write scope adds no
enforcement value, since the check status alone gates the merge.
Recommendation. Either accept the degraded fork behaviour explicitly, or
drop the comment option and pull-requests: write together.
6. Edge and operational surfaces¶
T-702 — Both confirmation gates self-approve without a TTY (Critical, verified)¶
Status (2026-08-28): FIXED —
Confirm-Planrefuses non-interactively instead of assenting, naming-Forceas the deliberate unattended path; the six destructive scripts declareConfirmImpact = 'High'.
scripts/lib/deploy-console.ps1:208-209 ·
scripts/bootstrap-terraform-oidc.ps1:110,352,420 ·
scripts/cutover/02-swa-token.ps1:35,55-59 ·
scripts/cutover/04-telegram-webhook.ps1:48,99-107
Confirm-Plan returns $true unconditionally when
-not [Environment]::UserInteractive -or [Console]::IsInputRedirected. That is
the single human gate in front of tenant-root elevation, workspace-variable
writes and the COSMOS_ENDPOINT secret delete. The second gate does not
prompt either: verified by enumeration, all eight scripts declare
SupportsShouldProcess without ConfirmImpact = 'High', so under the
default $ConfirmPreference = 'High' ShouldProcess returns $true
silently. The net effect is that
pwsh -File bootstrap-terraform-oidc.ps1 -ElevateAccess < /dev/null — a CI
runner, a piped invocation, a wrapper script — escalates to tenant-root User
Access Administrator with zero prompts, and 02-swa-token.ps1 -Rotate
invalidates the live SWA deploy token the same way.
Recommendation. Make Confirm-Plan refuse in a non-interactive context —
return $false with guidance — and require an explicit -Yes or -Force
switch to proceed unattended. Add ConfirmImpact = 'High' to every
destructive script.
T-703 — The root-elevation removal reports success on its own failure (Critical, verified)¶
Status (2026-08-28): FIXED —
Invoke-Azrecords whether the call failed,Test-LastAzFailedexposes it, and an unreadable read-back is now the red path with the manual removal command printed.
scripts/bootstrap-terraform-oidc.ps1:445-470 ·
scripts/lib/deploy-console.ps1:435-447
-ElevateAccess grants tenant-root User Access Administrator, then deletes it
with -AllowFailure and re-reads the assignments to confirm. But Invoke-Az
returns $null on both "no assignment found" and "the call failed" under
-AllowFailure — verified at lines 441 and 444-445. So a throttled,
network-failed or permission-denied role assignment list takes the success
branch and prints "Root-scope elevation removed (verified by reading
assignments back)" over a grant that is still live. The comment immediately
above states this exact false-green is what the read-back exists to prevent.
The residue is a standing tenant-wide privilege with no owner and no expiry —
the worst thing this repository's tooling can leave behind.
Recommendation. Have Invoke-Az distinguish failure from empty — a
sentinel return, or check $LASTEXITCODE at the call site — and treat an
unreadable result as the red path, never the green one.
T-704 — The T-526 cutover script has no working dry run (Critical, verified)¶
Status (2026-08-28): FIXED — the Key Vault window is
ShouldProcess-guarded, the verify block is skipped under-WhatIf, and the decorative secret line is replaced by a behavioural check (401 without the token, 200 with it) that also proves the vault token matches the deployed one. T-526 is safe to run.
scripts/cutover/04-telegram-webhook.ps1:63-72,133,146-153 ·
contrast scripts/cutover/03-keyvault-secrets.ps1:70
Three defects in the script that T-526 is waiting to run, all verified.
- The Key Vault firewall open at line 68 is not wrapped in
ShouldProcess, unlike its twin in03-keyvault-secrets.ps1. So-WhatIfstill mutates the production vault's network ACL and extractsTELEGRAM-BOT-TOKEN— the dry run is not dry. - Under
-WhatIfthesetWebhookcall is correctly skipped, but the verify block runs unconditionally and throws at line 153 when the URL has not changed. The dry run therefore always ends red and teaches the operator nothing. - Line 150 prints
custom secret: setfrom$(if ($after.has_custom_certificate -or $secret) …).$secretis always a non-empty string by that point, andhas_custom_certificatedescribes self-signed certificates, notsecret_token. Telegram'sgetWebhookInfonever returns the secret, so this line reads "set" unconditionally and verifies nothing.
Recommendation. Guard line 68 with ShouldProcess; skip the verify block
when $WhatIfPreference is set; replace the secret check with a real one —
re-POST to the target with the derived secret_token header and assert it is
no longer 401. Do this before running T-526, not after.
T-741 — The harness closes an empty workflow as completed (Medium, reported)¶
Status (2026-08-28): FIXED — such a close is now
empty, distinct fromabandoned; the CI assertion says so. Both harness scenarios were reproduced locally before pushing.
tooling/workflow.py:341-344,398 · .github/workflows/ci.yml:120-125
evaluate_workflow correctly refuses completed when the required set is
empty, falling through to partial. close_workflow then overwrites that:
it sets abandoned if there were errors and completed otherwise, with no
required check. A workflow whose every node was skipped as optional closes
as completed. The CI smoke test does exactly this — ci-probe is not in the
agent registry, so it is unavailable and treated as optional — and then
asserts the result is completed. The module's own docstring says the tool
exists to stop an audit trail that claims work which never ran.
Recommendation. Close as completed only when at least one node validated
and none were skipped; otherwise abandoned, or a new empty status. Update
the CI assertion to match.
T-742 — The harness CI check never exercises the validator (Medium, reported)¶
Status (2026-08-28): FIXED. A CI step now manufactures an available agent (none is committed, which is why every node took the unavailable branch), asserts it IS available or the step would silently re-test the broken path, then proves a valid handoff passes and two forgeries do not. Verified by mutating the validator three ways.
.github/workflows/ci.yml:117-137 · tooling/workflow.py:273-301,322-329 ·
hooks/claude_event.py:86-94
Both harness scenarios drive their nodes through the unavailable-agent branch,
so no handoff file is ever written and handoff_valid and scalar — the
regex parsing that decides whether an agent's claimed work is real — are never
called. A regression in the frontmatter regex, the workflow/agent id
cross-check, or the evidence-and-artifact requirement passes CI green, and the
Stop guard would then release on a workflow carrying fabricated handoffs.
Recommendation. Add a CI step writing a valid handoff for an available node
and asserting validate exits 0, plus a mutated copy (wrong agent id, missing
evidence) asserting exit 1.
T-743 — The vps-agent CI check runs no tests (Medium, reported)¶
Status (2026-08-28): FIXED.
buildDockerArgsextracted and pinned by 37 tests usingnode:test— no dependency added, since this package's single-dependency lockfile was its one virtue as a check. It also refuses a capability that sets a sandbox-controlled flag. The first version of the test was WRONG (it compared against the module's own constant, so--user 0:0passed); mutation testing caught it and the contract is now written out literally.
.github/workflows/ci.yml:49-50 · vps-agent/package.json:10-12 ·
vps-agent/lib/docker-runner.js:77-93
The matrix entry declares no lint, build or test step, so the job's only work
is npm ci — a lockfile check on a single dependency. There is no test
script and no test files anywhere under vps-agent/. This is the surface that
shells out to docker run with attacker-influenced payloads and holds a
long-lived Entra certificate; the sandbox flag list (--network none,
--cap-drop ALL, --user 65534, --read-only) is the entire security
boundary and nothing asserts it stays intact. An edit dropping --network none
ships green.
Recommendation. Add unit tests asserting the exact dockerArgs array
runInDocker builds per capability, that buildCommand never receives a
shell-interpolated payload, and that executeJob refuses an unknown job type;
wire test: npm test into the matrix row.
T-744 — maxConcurrentJobs is not enforced (Medium, reported)¶
Status (2026-08-28): FIXED — a
pendingClaimscounter covers the window between deciding to claim andexecuteJobowning the slot.
vps-agent/index.js:123,157-167,177 · vps-agent/lib/api.js:57
poll() checks activeJobs >= config.maxConcurrentJobs before awaiting
api.claimJob(), but activeJobs is incremented only inside executeJob,
after the claim returns. poll runs on a bare setInterval at 15 seconds
while the claim timeout is 20 seconds, so a slow claim lets a second poll pass
the guard with the counter still at zero. With the documented
LABS_AGENT_MAX_CONCURRENT=1 and a 256 MB / 0.5 CPU budget, two concurrent
Terraform containers is a real resource-exhaustion path on a small VPS.
Recommendation. Reserve the slot before the await — increment a pending
counter in poll(), include it in the guard, release it in a finally.
T-745 — The availability alert has no ingestion-lag headroom (Medium, verified)¶
Status (2026-08-28): FIXED — 30-minute window expecting 6 results, firing below 3; the Worker cadence comment moved with it, since the two must change together.
Status (2026-09-01): that was half true, and the wrong half. The threshold moved to 3 and every prose description — this line, the resource's
description, the inline comment,wrangler.toml— moved to "30-minute window".window_durationstayed atPT15M. Three rows per window against a threshold of 3 tolerates nothing, so the finding was not merely open, it was inverted: the pre-fix shape (PT15M, threshold 2) tolerated one miss and the "fixed" one tolerated none. Caught 2026-09-01 while preparing to armavailability_probe_alert_enabledfor the first time, so it never fired.window_durationis nowPT30M, matching the recommendation below and the four places that already claimed it.
edge/availability-probe/wrangler.toml ([triggers] crons) ·
infra/observability.tf (azurerm_monitor_scheduled_query_rules_alert_v2.edge_probe_availability) ·
edge/availability-probe/worker.js (buildEnvelope)
The analysis below is the state as found on 2026-08-28 and is deliberately
left as written — it is the evidence for the finding, not a description of the
rule today. Read the status lines above for that. (Same treatment as ADR 0025,
whose heading changed while its analysis did not.) Today the rule is PT30M
with threshold = 3.
The telemetry shape genuinely matches the alert — PROBE_NAME equals the
query's name == filter, the envelope is a well-formed
Microsoft.ApplicationInsights.Availability payload, and the */5 cron
against a PT15M window with threshold < 2 is arithmetically what the
comments claim. The gap is timing. The query window is [now-15m, now] with
no grace for ingestion delay, and App Insights availability rows typically
land one to three minutes late and occasionally more. At any evaluation the
newest one or two probes may not yet be queryable, so the "one dropped run is
tolerated" budget the ADR claims is already spent: a single lagged ingestion
plus one missed cron pages Sev 1 against a healthy site.
Recommendation. Widen to PT30M with threshold = 3 — still detects a
real outage inside about fifteen minutes while tolerating lag plus one drop —
or keep PT15M and drop the threshold to 1.
T-746 — A failed probe leaves no diagnosable trace (Medium, verified)¶
Status (2026-08-28): FIXED — Workers Logs enabled and the handler logs before rethrowing. Diagnosis, not recovery: the one-sided no-retry design is unchanged.
edge/availability-probe/worker.js:60-64,133-140,145-149 ·
edge/availability-probe/wrangler.toml
scheduled calls ctx.waitUntil(runProbe(env)) with no catch, and runProbe
deliberately does not retry or trap the ingestion POST. That one-sided design
is correct — but wrangler.toml declares no [observability] block, so
Workers Logs is off and the rejection goes nowhere. When
alert-api-reachability fires, the three causes it deliberately conflates —
API unreachable, Worker or cron dead, ingestion path dead — have no tiebreaker
anywhere. A mistyped wrangler secret put produces a permanent Sev 1 with
parseConnectionString throwing silently every five minutes. This matters
most at T-519 deploy time, when a bad secret is the likeliest first failure.
Recommendation. Add [observability] enabled = true to wrangler.toml,
and wrap the scheduled body in a try/catch that console.errors the reason
before rethrowing, so the Worker's own tail distinguishes the three cases.
T-747 — The Worker package is absent from Dependabot (Medium, reported)¶
Status (2026-08-28): FIXED — and it exposed something larger. Adding the entry made Dependabot re-validate the file, which failed: the
/infraterraform entry carriedcooldown.semver-major-days, unsupported for that ecosystem. An unsupported property invalidates the WHOLE file, so no ecosystem had been getting updates at all. Pre-existing and latent, because Dependabot only re-validates when the file changes. Fixed in the same PR. This is a finding the review did not make, surfaced by acting on its lowest-value item.
.github/dependabot.yml
The configuration lists /frontend, /functions, /scripts and
/vps-agent, but not /edge/availability-probe. Impact today is zero — the
package has no dependencies — so this is a completeness finding recorded so
that adding a dependency later does not silently enter an unwatched package.
Recommendation. Add the directory now, while it is a one-line change with no findings attached.
T-759 — Job images are pinned by mutable tag on a root-equivalent socket (Low, reported)¶
Status (2026-08-28): FIXED. All three images pinned by digest, each read from the registry's
Docker-Content-Digestheader and cross-checked against the Docker Hub API.capabilities.test.jsfails on a tag-only reference, and the update procedure is written beside the digests.
vps-agent/lib/capabilities.js:19,30,45 · vps-agent/lib/api.js:16-21
alpine:3.20, hashicorp/terraform:1.9 and alpine/ansible:2.17.0 are tags,
not digests, and the runner pulls implicitly at docker run time — with
--network none applied to the container, not to the pull. A repushed tag
changes what executes on the VPS with no repository change and no review.
Compounding it, the agent must have Docker socket access, which is
root-equivalent on that host: the certificate-credential blast radius argued
in api.js is bounded at the API but not on the VPS itself. There is no
systemd unit, Dockerfile or update runbook anywhere in the repository for this
component, and ClientCertificateCredential reads the PEM once at
construction, so certificate rotation requires a restart that nothing
schedules.
Recommendation. Pin each image by @sha256: digest and pre-pull out of
band. Run the agent as a non-root user in the docker group, and write the
install, update and certificate-rotation runbook.
Examined and found sound¶
Recorded so that the absence of a finding carries information.
Identity and access. Federated-only deploy identity with both subject
forms, scoped rather than subscription-level role grants, traced-to-consumer
grant hygiene, clean revocation records, and subject assertions under test
(scripts/oidc-subjects.test.mjs). Managed identity throughout the data
plane; shared keys disabled on both storage accounts and Cosmos local auth
off.
Backend security primitives. The three-gate role model (correct hierarchy
comparison, fail-closed on lookup error, unknown-role requirement throws,
bounded cache keyed post-verification); the public read surface (server-side
visibility filter in both SQL and JavaScript with the correct wide/narrow
asymmetry, TOP on every query, field projection rather than SELECT *,
byte-identical 404 for missing and non-public); the signed preview route
(HMAC over id and expiry, constant-time compare after a length check, uniform
refusal); the anonymous submission quota (compare-and-increment with a
loser-aware reset path, exact under burst); the rising-edge claim evaluator
(etag-conditioned with 412 re-read, unreadable timestamps refused rather than
taken over); the AI router (timeout, bounded retry, provider-unusable versus
bad-request failover, single repair round trip); the SSRF guard in
fetch-image.js; the Telegram webhook's constant-time secret comparison; and
the job worker core's etag-conditioned claim and timeout race.
Contract integrity. .azure/api-surface.json and the registered routes
agree bidirectionally at method level, with duplicate-claim detection and
notImplemented enforced as unregistered. No drift found.
Terraform discipline. The root-module shape was re-evaluated against ADR
0020's revisit triggers rather than assumed (none have fired); credential-free
provider blocks with the bootstrap-identity comment the standard requires; a
committed lock file with the wrong-workspace hazard documented; exactly one
ignore_changes block in the estate and it is justified; both azapi uses
re-verified as still necessary on azurerm 5.1.0 with pinned api_versions and a
documented removal condition; account-level lifecycle guards; every variable
typed, described and validated with no dead entries; the out-of-band custom
role following the standard's bootstrap-split pattern.
CI supply chain. Every third-party action across all thirteen workflows is
pinned to a full commit SHA with a version comment, and Trivy was replaced by
a checksum-verified pinned binary after the marketplace compromise. Every
workflow declares an explicit permissions: block and nothing defaults to
write-all. No pull_request_target anywhere, and all PR-triggered workflows
hold zero cloud credentials by explicit doctrine. No untrusted input reaches a
run: string apart from T-756. The "always report the context, filter inside
the job" pattern in iac-validate.yml correctly avoids the stuck-"Expected"
trap while keeping infra checks required.
Frontend correctness gates. The prerender refuses to publish
error-boundary output, 404-page renders and sub-420-character shells, and
generates the sitemap from routes actually written, so sitemap, canonical and
disk cannot disagree. Canonical and trailing-slash policy is coherent.
Keeping app-shell.html distinct from dist/index.html correctly avoids
serving home-page content at HTTP 200 for arbitrary URLs. /preview isolation
holds on all four mechanisms. CSP carries no unsafe-inline in script-src
and is guarded by a test tying connect-src to the API base. Asset caching
rules are right for hashed-asset delivery. usePublicData's race safety and
prerender seeding are correct.
Edge probe. Secret handling is write-only, out-of-band, and loud on absence; failure semantics are deliberate and all four paths are tested; the telemetry matches the alert exactly. The two findings against it (T-745, T-746) are operability, not correctness.
VPS agent design. No data-plane credential, server-side capability authorization, certificate rather than shared secret, argv-array command construction, and a thorough container sandbox. Its findings are about test coverage and operations, not the security model.
Remediation, 2026-08-28¶
35 of the 62 findings are fixed, one is recorded as will not fix with its reason, and the rest are listed in TODO.md. Each finding above carries its own status line; this section records what the remediation taught that the review itself did not know.
Two findings were wrong, and following them caused harm.
- T-750 asked for the function app's CORS origins to be derived from
var.domain. Doing so brokefunctions (azure):cors-platform-origins.test.jsreads that block as TEXT and compares literal origins againstcors.js, so an interpolation is not a string it can compare — and the comment placed inside the block broke its regex outright, which is worse, because the guard then passes while checking nothing. Reverted; the literals are load-bearing. - T-715 correctly diagnosed a 456 kB chart bundle on every page, then
prescribed a fix that does not work. rolldown places React's jsx-runtime by
its own rules, so claiming react in an earlier chunk moved only
scheduler, and removing the manual chart chunk made the critical path worse. What worked was splitting the libraries, measured on built output: 868 kB → 571 kB.
One finding was understated. T-701 reported that publish-content
inherited the wrong role. Verification found that none of the eight job types
declared a role at all, which made the fix structural (require it in
registerJobType) rather than a one-line patch — and the new validation
immediately caught a ninth registration the review had missed.
One finding exposed something larger than itself. T-747 was the lowest-value
item in the review: add /edge/availability-probe to Dependabot, "zero impact
today". Adding it made Dependabot re-validate the file, which failed — the
/infra terraform entry carried a property unsupported for that ecosystem, and
an unsupported property invalidates the whole file. No ecosystem had been
receiving dependency updates at all. Latent because Dependabot re-validates
only on change, and nothing had changed that file since the property was
introduced.
What the remediation confirmed about method. The Terraform plan succeeding
with prevent_destroy in force is what proves T-706's replication change is an
in-place update rather than a replacement — a green check that means something
specific. Conversely, two CI failures came from changes verified by inspection
rather than execution, so Terraform is now run locally before pushing, and the
backend suite is re-run after infra changes: a test source-scans main.tf, so
"infra-only" is not a category that exists here.
Disposition¶
The five Critical findings are the natural first work item, and T-704 has a
sequencing constraint: it should land before T-526 is executed, because that
script's -WhatIf currently mutates the production Key Vault rather than
simulating.
After those, the three data-durability findings (T-706, T-707, T-708) are the ones whose cost is unbounded if they are ever exercised, and two of the three are small changes.