Client Access Checklist

Skill packs › Skill

Client Access ChecklistIN EVERY PACK

The standard set of accounts and permissions we capture before any content ships — Search Console, CMS admin, GA4, Tag Manager, DNS, Google Business Profile, video, social, ads. Use at kickoff on any new client or site, whenever a build is about to publish, and whenever you are asked “do we have access to X”. Turns access from something we remember into something we audit.

Skill file client-access-checklist.md · last updated Aug 12, 2026

How to run it. Download any pack from the skill-pack directory, unzip it into your Claude project folder, and this skill is one of the files inside. You do not paste it anywhere — the agent reads it when the job calls for it. This one ships in every pack.

Use this when starting any engagement, standing up any site, or about to publish content anywhere. Also use it the moment you catch yourself about to write a “needs the client’s login” list — generate that list from here rather than from memory.

The rule this skill enforces: a build is not done when the content is live. It is done when the content is live and we can see what it does.

Why (read this once, it changes how you work)

We published sixteen articles to a client site on June 14, 2026. Six of them were never indexed by Google. We did not find out for six weeks, because that site had no Search Console property — and it had no Search Console property because our coverage audits took their list of sites from the hosting fleet, and this was a site we publish to but do not host. It was structurally incapable of showing up as a gap.

The same audit found that site had no analytics of any kind. A fleet-wide sweep found 127 more properties in the same condition, 50 of them on our own hosting.

Nobody skipped a step. The step wasn’t on the list. This skill is the list.

The nine rows

Rows 1–4 are the gate. Content does not ship until they are green.

# Access What “green” means
1 Google Search Console Property verified · our operating account added · sitemap submitted, Success
2 CMS / website admin Admin login and an application password in .credentials.json so agents publish without a human
3 Google Analytics 4 Property live, tag firing, internal traffic filtered, linked to GSC
4 Google Tag Manager Container published with a real ID — an empty container is a false green
5 Registrar + DNS Login or a named responsive contact; record where DNS is hosted
6 Google Business Profile Claimed; we hold manager access (this is NOT the same as a personal Knowledge Panel)
7 YouTube / video Channel access or a reliable export path — this is the raw material for repurposing
8 Social profiles Handles confirmed and recorded for sameAs; access where amplification is in scope
9 Ad accounts Business Manager access with the right role, if ads are in scope

Steps

  1. Check the register first. Access-Checklist/ACCESS-REGISTER.json may already have the

property. Then check .credentials.json for an application password before asking anyone for a login — we frequently already have access nobody remembered.

  1. Run the audit for this domain: python3 Access-Checklist/access_audit.py --domain <domain>.

It reports verification evidence, analytics, sitemap, robots reference, and publish access from public HTML in seconds. Do this before asking the client for anything, so you ask only for what is genuinely missing.

  1. Ask once, ask completely. One message listing every missing row with the exact emails and

roles. Ten small asks over six weeks is how access work dies.

  1. Verify Search Console per blitzmetrics.com/verify-client-sites-google-search-console/:

URL-prefix property → copy the HTML-tag token → paste into the site’s head → Verify → submit sitemap.

  1. Add users. The site owner’s Google account = Owner. Your operating/service account = Full.

Your own account = Full. ⚠ The Add-user click is a HUMAN step — agents do not modify access controls. Prepare everything, then hand off. (This file ships in public download packs. The specific role addresses we use are in the internal runbook — Access-Checklist/ACCESS-CHECKLIST.md — never in a distributed skill.)

  1. Install GA4 and GTM before the first article, not after.
  2. Record everything in the register and .credentials.json, then re-run the audit to confirm

the rows went green. Access you did not record is access you do not have.

  1. Route what you cannot get. Blocked is a status, not an ending. Anything an agent cannot do

goes to your ops owner as a written to-do with a FULL spec — not a hint. Host or server work goes to whoever owns the server, in writing. Name the owner and name a date: a blocker with an owner and a date is progress; a blocker in your head is not. (Internal routing targets live in the internal runbook, not in this shipped file.)

Definition of done

  • [ ] Every one of the nine rows has a status and an owner — including the ones we chose to skip
  • [ ] Rows 1–4 green, or a routed blocker naming exactly what we cannot see until it clears
  • [ ] Property present in ACCESS-REGISTER.json; credentials in .credentials.json
  • [ ] access_audit.py --domain <domain> re-run and the gaps it reports match what you expect
  • [ ] Sitemap submitted, and referenced in robots.txt
  • [ ] The client-facing “what we need from you” list was generated from this checklist, not memory

Traps met in the field

  • Rank Math’s updateSettings REST endpoint cannot safely write one setting. It replaces three

whole option groups and will destroy settings. Use a browser REST nonce or the bm-site-verification module. Never inject a verification token through it.

  • Block-theme sites with no SEO plugin have no head-injection route over REST. That is what

GSC-Fleet-Coverage/bm-site-verification.php exists for — built and tested, pending deploy.

  • Some of our own sites 403 Google’s Google-Site-Verification/1.0 fetcher while serving

Googlebot normally. Allowlist the user agent before blaming the token.

  • Flush the sitemap cache before submitting. One site listed 1,059 URLs against 1,447 published

articles; submitting as-is would have hidden 393 articles.

  • An empty GTM container reads as installed. Check for a real GTM-XXXXXX ID, not just the

presence of gtm.js.

  • A missing verification meta tag does not prove there is no Search Console — DNS TXT, GA, and

GTM verification leave no HTML trace. GSC_UNCONFIRMED means check the account.

Related

  • weekly-brand-maa.md — the weekly loop that consumes this access; its indexation check is what

exposed the gap

  • measurement-analytics.md — what to do with the data once you can see it
  • personal-brand-website-agent.md — the build this checklist gates
  • TaskLibrary/skills/digital-plumbing/ — task-level SOPs for each row
  • boil-the-ocean.md — why the answer is the finished access, not a plan to get it

Learned in the field

Appended automatically by the self-improvement loop (Skill-Learnings/): dated lessons from real runs. Newest at the bottom.

<!– learning:2026-07-29-read-the-plugin-source-before-writing-to-its-table –> July 29, 2026 (from: collapsing 24 redirect chains across localservicespotlight.com + dennisyu.com, July 29, 2026)

When a plugin’s REST API is undocumented, download the plugin and read it

Two redirect tables needed surgical edits and neither API was documented in a way that answered the only question that mattered: what happens to the fields I do not send? Guessing against a live table with 34 and 192 rules on it was not acceptable, and probing by trial risked detaching real redirects carrying thousands of hits.

So both plugins were downloaded from the wordpress.org repo at the exact installed version and read. Ten minutes, and it turned two unknowns into contracts:

  • RankMath Redirections has no route that LISTS redirections. The read path is

status/exportSettings (redirections ride along in the export). The write path is updateRedirection, which is really the post-metabox save handler: it rebuilds the rule from (redirection_id, url_to, sources, header_code). Omit redirectionSources and the source is rebuilt EMPTY, silently detaching the rule from the URL it exists to catch. It also rejects an empty objectID, which a standalone rule does not have — pass an id matching no post, because a real one makes that post’s metabox claim it owns a redirect.

  • Redirection plugin (redirection/v1/redirect) sanitises the whole payload and then does a

full row UPDATE. A partial patch drops every field you left out. The payload has to be the GET item echoed back with only the target changed — and hits / last_access must be OMITTED, because the sanitiser maps them onto last_count / last_access and rewrites the counters.

  • Its import path is safe but useless for repairs: set_redirections() skips any row whose

source already matches an existing rule, so re-importing corrected copies changes nothing.

Rules:

  1. Read the source before the first write, not after the first surprise. curl the versioned

zip from wordpress.org, unzip, read the sanitiser and the update method. Cheaper than one bad write to a production table.

  1. Find out whether update means PATCH or REPLACE. If the model sanitises into a fresh array

and calls $wpdb->update with it, every field you omit is erased.

  1. Audit every redirect engine a site has, not the first one you find. dennisyu.com runs

RankMath Redirections AND the Redirection plugin at once. X-Redirect-By proved the plugin wins; fixing the RankMath copies changed nothing a visitor could see — 2 of 13 “fixes” held and the rest still measured 3-4 hops. X-Redirect-By on the response is the ground truth for who is actually in charge.

  1. Never auto-rewrite a regex rule. Its source is a pattern, not a URL, so hops cannot be

measured, and its target may contain capture groups. Report it and stop.

  1. A rule pointing at itself is a live infinite loop, not a chain. Detect “never lands after

N hops” as its own class and never auto-repair it — the correct destination is an editorial decision. One such rule had taken 12,190 hits.

  1. Check the trailing slash on high-volume rules. A target of /$1 where WordPress canonical

wants /$1/ costs an extra 301 on every request. On the busiest rule on the site that was 829,576 requests each paying for a hop nobody needed.

Learned July 29, 2026.

<!– learning:2026-07-30-a-credential-that-arrives-is-not-a-credential-that-works –> July 30, 2026 (from: Sigrun website-request intake, July 30, 2026 — Christine Boers-Doets application password)

A credential that ARRIVES is not a credential that WORKS. Christine Boers-Doets’ WordPress Application Password was the single blocker on a finished site for 13 days. The morning it finally came in — pasted by Muzamil in an email alongside Jagoda’s — the reflex was to mark the blocker cleared, store the key, and publish. That would have been wrong for the second time on this same project. The key does not authenticate. Tested against the live .nl install under 15 usernames (the two real users the REST API lists, plus admin, operations, blitzadmin and nine more): every one returns rest_not_logged_in, the same 401 a request with no credentials gets.

The rule: probe a freshly-received credential against the LIVE target before you advance a status, store it, or report the block cleared. And probe it with a check you have PROVEN can both pass and fail in the same breath — a check that cannot fail is not a check. Here the proof was a positive and a negative control run right next to the real test: Jagoda’s key returned 200 on /wp/v2/users/me?context=edit and on /wp/v2/settings, and a deliberately bogus key returned 401. Only against those two poles does Christine’s key returning 401 mean something. Without them, 401 could just as easily have been the whole site rejecting Basic auth, and “her key is bad” would have been an unfounded guess.

The same-batch control is what turned a vague “the credential doesn’t work” into a precise, actionable finding. Jagoda’s key came from the identical email and authenticated cleanly — so it is not the email, not the paste channel, not the fleet, not the WAF. The fault is isolated to this one key or the install it was minted on. That specificity is the difference between an email that says “please look at Christine’s password” (which generates a reply asking what’s wrong) and one that says “re-mint it on the .nl install, SiteId 2360, the christineboers profile” (which gets done).

The most likely cause carries its own reusable lesson: on a domain that was re-mapped between TLDs, the old and new installs are SEPARATE WordPress sites. Christine’s site moved from .com (SiteId 2348) to .nl (SiteId 2360); the .com now resolves to a GoDaddy/Fastly parking page and its old WordPress on our IP no longer serves. An Application Password minted on the old install fails on the new one with the exact same rest_not_logged_in as a wrong password — indistinguishable by status code alone. When a key fails, name which install it was probably minted on rather than reporting “bad credential”; the fix (re-mint on the correct install) is invisible until you do.

One trap worth flagging: /wp/v2/users only lists users who have published posts, so the real service account can be invisible to it. Jagoda’s working key belonged to WP user #1 admin, which the public user list never showed. So when a key fails on the two listed users, try likely service usernames (admin / operations / blitzadmin) before concluding anything — the account it was minted under may not be one the API will name for you. Here all 15 still failed, which is itself the finding, not a reason to stop early.

<!– learning:2026-08-01-a-named-blocker-needs-a-recheck-date –> August 1, 2026 (from: skill-pack-propagation — closing out the agent-runtime cloud migration, August 1, 2026)

A plan that says “blocked on <person>” quietly becomes a plan that is blocked on nobody reading their reply. The cloud-migration runbook had said “Phase 1: waiting on Josh or Austin to create the repo and issue a bot token” since July 17, 2026. Daniel created the project and sent BOTH access tokens on July 21, replying to that very thread. Nobody opened it. The runbook, the project memory and every status summary kept reporting a human blocker for eleven more days, while the thing they were waiting for sat in the inbox — and the whole migration, plus the audit-trail benefit that depends on it, stalled for a reason that had already gone away.

The general shape: a blocker recorded as a person is only as fresh as the last time someone checked the channel that would clear it. Writing “waiting on X” creates an obligation to re-read, and nothing in the plan carried that obligation. Every named external dependency needs one of: a re-check date, an owner for the re-check, or — best — an automated probe that tests the actual precondition rather than trusting the note. Here the probe is two seconds of work: git ls-remote against the repo would have returned success on July 21 and every day after.

Test the PRECONDITION, not the memo about the precondition. This is the same error as trusting a “no skills changed” report instead of hashing the files: in both cases a status line was believed because it was written down, when the underlying state was one cheap call away.

Corollary on credentials that arrive by email: treat them as already-exposed. Both GitLab tokens came through in plaintext, so they were compromised the moment they were sent, regardless of what we do next. Store them in the one secret file, use them, and schedule a rotation — do not pretend that careful handling afterwards undoes an insecure delivery.

Fixed August 1, 2026: tokens stored in .credentials.json (read at push time by tools/git_askpass.py, never embedded in a remote URL), 9,023 files pushed live, and the runtime mirror now re-syncs, commits and pushes automatically as step 9/9 of the daily runner — so its freshness no longer depends on anyone remembering.

<!– learning:2026-08-01-a-quiet-queue-may-be-a-disconnected-one –> August 1, 2026 (from: sigrun-website-request-intake)

A daily job can be perfectly correct and still be watching nothing. When a source returns the same answer for weeks, verify that anything upstream is connected to it.

The Sigrun website intake read its Google Form sheet accurately every morning for sixteen consecutive days and truthfully reported “no new requests, 1 row.” The read was never wrong. The form had never been shared with members. Seven people were waiting in queues the job could not see, because every real request arrived by email, by Basecamp, or directly to Dennis. The finding did not come from the data — it came from a client asking, on the project thread, “please share a list of who is waiting.”

Adopt as standing practice:

  1. An unchanging source is a hypothesis, not a fact. Two weeks of identical output means

either nothing is happening or nothing is arriving. Those have different owners and different fixes. After N consecutive no-change runs, spend one call confirming the upstream path is live — that the form is published, the webhook fires, the inbox is monitored, the sheet is the one people actually submit to.

  1. Count the queue, not the source. A pipeline’s real backlog is the union of everything

feeding it. Keep an explicit lane for out-of-band arrivals (here, tracked_non_form) and treat it as first-class, because in a system where the front door is closed it is the whole queue.

  1. Reconcile against sister pipelines before reporting scope. Two agents were tracking

overlapping subsets of the same work — this job knew two members, another knew eight. Neither was wrong; both were partial. One read of the other agent’s outbound email closed the gap. When you know another automation touches the same domain, diff against it.

  1. “No changes” is only a safe report if you checked what would have made it change.

Green on an unconnected input reads exactly like green on a healthy one, and it buys silence for as long as nobody outside asks.

<!– learning:2026-08-01-decode-xml-entities-before-fetching-sitemap-children –> August 1, 2026 (from: wtp-monthly-seo-reaudit run (Western Trading Post, first tracked run))

Decode XML entities before fetching sitemap child URLs — or you will report a healthy sitemap as dead

Checking whether a client’s /sitemap.xml fix had shipped, the index at /xmlsitemap.php returned 200 and listed five child sitemaps. Fetching each child returned 404 with zero bytes, all five. The obvious read was that the sitemap fix was cosmetic — index alive, every child dead, zero URLs discoverable by Google. That was about to be the report’s headline finding, and it was completely wrong.

<loc> values are XML-escaped. The real URL is ?type=pages&page=1; the sitemap contains ?type=pages&amp;page=1. Fetching the raw captured string sends a literal &amp;, the parameters break, and the server 404s. Decoding entities first, all five children return 200 with 4,516 URLs.

Rules:

  1. Always entity-decode <loc> values before fetching them&amp; &lt; &gt; &quot; &apos;.

This bites hardest on sitemaps with query-string pagination, which is the norm on BigCommerce, Shopify and most hosted carts.

  1. A 100% failure rate across every child is a smell, not a finding. Real breakage is

usually partial. When every single item in a set fails identically, suspect the harness before the target — the same instinct that max_crawl_pages taught on the SERP side.

  1. Never report an infrastructure catastrophe from a single method. Confirm with a second

path (browser navigation to one child URL, or Search Console’s sitemap report) before telling a client their sitemap is dead. The credibility cost of a false alarm this size is far higher than the minute it takes to check.

Same run, same discipline, two more times: a robots.txt parser that reported “zero crawlers blocked” was prove-red tested against a synthetic blocking file first (it correctly caught 2/2) before its zero on the live file was trusted, and cross-checked against a raw count of bare Disallow: / lines. And a +46% referring-domain jump — exactly the shape of a mode/measurement artifact — was confirmed as real by pulling refdomains-history and seeing a steady 13-week climb before it was narrated as growth.

General form of all three: when a check returns the answer you were hoping for, or an answer too dramatic to be ordinary, make it prove itself before it reaches the client.

Learned August 1, 2026.

<!– learning:2026-08-01-does-not-resolve-is-not-is-not-registered –> August 1, 2026 (from: sigrun-website-request-intake)

“Does not resolve” and “is not registered” look identical to dig +short and have completely different owners. Ask the TLD’s own authoritative server, with controls.

Four member domains were on a live ops ticket that said “point the A-record at the fleet.” Two of them — anneliesalminen.com and piamuggerud.com — are not registered at all. There is no zone to add a record to. dig +short A returned empty for them, which reads as “DNS not configured yet” and had been reported that way for four weeks. Ops would have gone looking in GoDaddy for zones that do not exist.

Adopt as standing practice:

  1. Query the registry, not a resolver. dig @a.gtld-servers.net <domain> NS and read the

rcode: NXDOMAIN means no registry delegation (unregistered, or registered with no nameservers — either way the fix is at the registrar). NOERROR with an NS referral means registered and delegated, and only then is “add an A record” a real instruction.

  1. Run both controls in the same call or the result is unfalsifiable. A made-up domain as

the negative control (must return NXDOMAIN) and a known-good domain from the same batch as the positive control (must return an NS referral). Without them, an NXDOMAIN could be a network artifact. With them it is a finding you can put in front of a client.

  1. Before repointing any domain at the fleet, fetch its root and read the title. The

email-safety rule catches MX; it does not catch a working homepage. lisasennhauserkelly.com matched every “built but never provisioned” description in the queue while serving the member’s real live business site at HTTP 200. Repointing it would have destroyed live work. Check for a live site and live email, separately.

  1. Correct a wrong public status the same day you find it. Two of eight entries on a

client-visible list were mis-diagnosed as waiting on our DNS. Left alone, the members would have waited on an action nobody could take. Naming the real blocker moved two items from an ops backlog to a one-message ask.

<!– learning:2026-08-01-read-the-channel-before-reporting-a-missing-data-source –> August 1, 2026 (from: wtp-monthly-seo-reaudit run (Western Trading Post) — GSC reported as “not configured” while a teammate posted GSC data weekly in the same thread)

A task parameter that names a missing data source is a claim with an expiry date — check the client’s own channel first

This monthly audit’s parameters said gsc_property: not configured — Ahrefs + direct crawl only. The run believed it, wrote “No Google Search Console property is configured” into the client-facing report as a finding with an owner, and listed “get GSC verified” as an action.

Then the run opened the client’s Basecamp thread to post — and found our own operations teammate posting Search Console data in that thread every single week: ~120K impressions, 3.5% CTR, average position 8.4, top queries with click and impression counts. The property existed. It had existed the whole time.

Two costs, and the second is worse than the first:

  1. We nearly asked a client for access they had already granted — the exact move that burns an ask and makes

the retainer look inattentive.

  1. We did the analysis without the best data we had. Ahrefs estimates rankings; Search Console reports

what actually happened. The GSC query table turned out to contain the single most valuable finding of the engagement — 4,419 monthly impressions on one dead craftsman’s name, landing on a sold lot page. That insight was sitting in a teammate’s weekly report for six weeks and the “authoritative” monthly audit never opened it.

Rules:

  1. Before reporting any data source as missing or unavailable, read the client’s own channel — the

Basecamp thread, the shared drive, the weekly report someone else files. A per-client agent’s parameters are a snapshot of what was true when the task was written; access changes and nobody edits the task.

  1. When you find the parameters wrong, fix the parameters, not just the report. File it as an ask against

yourself in the ledger. A correction that lives only in one month’s write-up gets re-derived — and re-published as a false finding — next month.

  1. Sibling reporting is a data source, not just context. The existing 2026-07-20 learning already says

“check sibling scheduled tasks’ outputs before declaring a metric blocked.” Extend it: check what humans on the account are already reporting, in the channel you are about to post into. Read the channel before you write to it.

  1. Corollary on credit: when you use a teammate’s numbers, say whose they are. The client should see one team,

and the teammate should see their work being built on rather than quietly re-derived.

This is the same family as the 2026-07-31 lesson that “blocked is a claim that needs evidence” — but a rung earlier. There, a real blocker was misdiagnosed. Here, a non-existent blocker was inherited from a config file and published without anyone testing it once.

Learned August 1, 2026.

<!– learning:2026-08-02-same-origin-required-before-trusting-an-empty-search –> August 2, 2026 (from: WTP auction-tracking investigation — five Basecamp searches returned zero because they ran cross-origin from a client site)

An in-page fetch to another origin fails silently — and an empty search result looks exactly like “no history exists”

Asked to mine years of Basecamp history for prior conversations about a client’s auction platform, the run issued five in-page fetch calls to Basecamp’s search endpoint and got zero results for every query. The obvious conclusion was that the team had never discussed it.

The tab was sitting on auction.westerntradingpost.com. Every one of those fetches was cross-origin and was rejected by the browser before it left. The catch block swallowed it. Zero results was never an answer about Basecamp; it was an answer about CORS.

Run properly, the same searches returned 11 hits, and the history contained the single most valuable fact of the whole investigation: the client’s tag stack was already installed on the auction platform, and a 9-month-old access request had dissolved into an unrecorded phone call.

Rules:

  1. Check location.host before trusting any in-page fetch result. If you are not on the origin you are

querying, the result is meaningless. Navigate first, then query.

  1. A search that returns zero needs a positive control before you report “nothing exists.” Run a query you

know has hits through the identical code path. If the control also returns zero, the harness is broken, not the archive. This is the same prove-red discipline used for the robots.txt parser — extend it to every negative finding, because a negative finding is the easiest kind to fake.

  1. A second failure mode stacked on the first here: even same-origin, Basecamp’s search results are

client-rendered, so fetch + DOMParser returned a shell with zero result anchors while the live page showed 53. When a fetch of a modern web app returns structurally empty results, read the rendered DOM after navigation instead. Two different mechanisms, one identical symptom: a confident, wrong “nothing found.”

  1. **”No prior discussion” is a claim about an archive, and archives are exactly where an agent’s memory

advantage lives.** Getting it wrong does not just lose a fact — it wastes the institutional knowledge the client already paid for, and re-asks colleagues questions they answered months ago.

Learned August 2, 2026.

<!– learning:2026-08-02-a-resent-credential-means-the-ask-was-ambiguous –> August 2, 2026 (from: Sigrun website intake — day 16 of a blocked publish; the third escalation was answered by re-sending the same failing key)

When someone answers a request for a new credential by re-sending the old one, the request was ambiguous — not ignored

A member’s finished site sat unpublished for sixteen days on one missing Application Password. Three escalations went out over four days: a comment on an assigned to-do, a line on the client thread, and an email. Each asked, in good faith, for “an Application Password for <domain>.”

The reply arrived with a key and a link to where it had first been posted. It was the same key that had been failing since day 12 — minted on the member’s old .com install, which is a separate WordPress from the live .nl site she was moved to. It returns 401 under every username, and the sender’s own status export said hasAppPass:false for the new site.

Nobody dropped anything. The ask named a domain and a credential type, and the person doing the work reasonably read that as “send the password for that domain” — and one existed. The word that was missing was the verb, and the target.

Rules:

  1. Ask for the verb and the install, not the credential and the domain. “Mint a NEW Application

Password ON <domain> (SiteId NNNN), user <login>, name cowork-<person>” cannot be satisfied by forwarding an existing key. “Send me the app password for <domain>” can.

  1. Re-asking the same way gets the same answer. After the second identical reply, change the

wording rather than the channel. Three escalations through three channels all carried the same ambiguous sentence, so all three produced the same outcome.

  1. State the disqualifying evidence in the ask. “The key on file returns 401 under all four

usernames, tested today against a probe that returned 200 for a known-good key in the same run” tells the reader the existing key is not the answer, before they reach for it.

  1. When a domain is re-mapped between TLDs, the two are separate WordPress installs, and a key

minted on the old one fails on the new one with the same rest_not_logged_in as a wrong password. Name which install a key was likely minted on rather than reporting “bad credential.”

Learned August 2, 2026.

<!– learning:2026-08-02-successfully-set-up-is-not-reachable –> August 2, 2026 (from: Sigrun website intake — the fleet provisioned WordPress sites for two domains that do not exist, and mailed a success notice for each)

A provisioner reporting success tells you the installer ran, not that anyone can reach the result

Four member domains went through our New Site form in one evening. Four “Your new WordPress site has been successfully set up at https://…” emails came back. Two of those domains return NXDOMAIN at the .com registry — no delegation, no nameservers, no zone. They are not registered.

The installs are real. A Host: header probe to the fleet IP returns proper WordPress pages with the members’ names in the title. Everything downstream — the setup email, the fleet record, the status list posted to the client — says done. Nobody on the internet can type an address that reaches either one, and nothing in the pipeline is capable of noticing.

The same day, a related failure in the opposite direction: a member’s site was recorded as 503 at check time and left there. Re-checked once, it 301s to www and serves her real 837 KB business site. A single non-200 had been treated as a verdict.

Rules:

  1. Resolve the hostname before reporting a provisioning success. One DNS lookup at the end of the

installer separates “installed” from “reachable.” Without it, “successfully set up” is a claim about our filesystem dressed as a claim about the member’s site.

  1. **dig +short cannot distinguish “not registered” from “registered, no records” — both print

nothing.** Ask the TLD’s own authoritative server (dig @a.gtld-servers.net <domain> NS) and read the rcode: NXDOMAIN means no registry delegation; NOERROR with a referral means registered. Run an invented domain and a known-good domain in the same call, or the result is unfalsifiable.

  1. Check our OWN records before escalating to the person. Our build logs said all four domains

were “registered 2026-07-01, GoDaddy, 1yr — gift.” Two do not exist, which turns “ask her where she registered it” into “check whether our own purchase completed” — a question we can answer ourselves, and a far better thing to say to someone who was told her site was coming.

  1. A 503 is a moment, not a verdict. Retry it, and follow the redirect — the root may 301 to

www and only the www response carries the site. A non-200 feels like an answer, which is exactly why it gets recorded as one.

  1. Where a wrong action is unrecoverable, the refusal belongs in the tool. Two of these domains

serve live business sites and two do not exist; from the fleet’s side all four look identical to “built, just needs provisioning.” The publisher now refuses those four by name with the reason printed and an explicit override flag — and the refusal was tested for both outcomes, because a guard that has never been seen to fire is not known to work.

Learned August 2, 2026.

<!– learning:2026-08-05-a-rotation-announced-is-not-a-rotation-applied –> August 5, 2026 (from: skill-pack-propagation daily run)

The push failed with a 403 and the fix had been sitting in the thread for two days — we were presenting a superseded credential

The daily run’s ledger push to git.adectra.com/blitzmetrics/agent-runtime returned 403 “You are not allowed to upload code.” The instinct — and the first draft of the escalation — was “the remote changed, ask a human to restore our access.”

That was wrong, and one rule caught it: an auth error tells you the credential presented was refused, never that it was the credential you meant to present. Proving which one went over the wire took three checks and about a minute:

Check Result What it ruled out
git ls-remote succeeds not a network/host/outage issue
GET /personal_access_tokens/self active, not revoked, write_repository, expires 2027-07-20 not an expired or revoked token
push to a non-master ref same 403 not branch protection — receive-pack is refused outright
token created_at 2026-07-21 17:27 ← the answer

That creation timestamp is three minutes before the email that originally delivered it. Two days earlier, in the same thread, the person who owns those tokens had written: “I have created new GitLab tokens and shared them with you via LastPass. Let me know when you’ve swapped those and I’ll remove the old tokens.” He had begun narrowing the old ones. We were still presenting the July token, so it read (leftover role) and could not push.

Rules:

  1. A rotation announced in prose is not a rotation performed. “New credentials are in

the vault” is a message, not a change. Until someone swaps the value and a real call succeeds with it, the system is running on the old one.

  1. Make the swap a task with an owner and a due date, not a sentence in a thread. The

half-completed rotation is the dangerous state: the old credential still half-works, so nothing fails loudly until something does, days later, in a scheduled job at 4am.

  1. Ask the vendor’s API who you are before you ask a human what changed. Name, scopes,

created_at, revoked — four fields that convert “the remote is broken” into “our copy is stale,” and they are readable with the very credential that is failing.

  1. created_at on a credential is a diagnostic, not trivia. Compare it against the last

rotation you were told about. If it predates that conversation, stop debugging the remote.

  1. Escalate with the answer, not the symptom. The draft that went out says “we never

applied the rotation you told us about, don’t revoke yet, I’ll swap and confirm” — which costs the other person nothing. The draft that nearly went out asked him to investigate a permissions regression that did not exist.

The pattern underneath, which has now happened twice on this exact thread: in July a reply containing both tokens sat unopened for eleven days while our own notes said “waiting on GitLab.” In August a rotation sat unapplied for two days while the job reported a remote failure. Both times the answer had already arrived and nothing was watching the channel it arrived on. A plan that names an external blocker is only as fresh as the last time someone checked the place the unblock would appear — and a job that reports “they refused us” when the truth is “we never updated” sends everyone looking in the wrong direction.

Learned August 5, 2026.

<!– learning:2026-08-09-test-what-wins-the-chain-not-your-entry-in-it –> August 9, 2026 (from: skill-pack-propagation (2026-08-09 run))

When a system resolves something through a priority chain, verifying YOUR entry in the chain proves nothing. Verify what wins.

Step 9 pushes the jobs ledger to GitLab. It exports GIT_ASKPASS so the token is read from .credentials.json and never written into a remote URL, .git/config or ~/.git-credentials. That design is right and it was implemented correctly. It still sent the wrong credential for four days.

Git asks every configured credential.helper FIRST and only falls through to GIT_ASKPASS when they all come up empty. macOS ships credential.helper = osxkeychain in the Command Line Tools system gitconfig, so on this machine the keychain is permanently in front of us. At 2026-08-05 02:09 UTC an internet password for git.adectra.com was written to the keychain under the account claude-audit. From that minute git_askpass.py was never executed — not once — and every push presented claude-audit.

Why three days of evidence pointed at an innocent party. claude-audit has read access, so every diagnostic we owned came back green:

check result what it actually tested
git ls-remote origin 200, refs listed that SOME identity can read
GET /personal_access_tokens/self active, not revoked, write_repository the token we MEANT to send
POST .../info/refs?service=git-receive-pack with our token HTTP 200 the token we MEANT to send
git push 403 “You are not allowed to upload code” the token git CHOSE

Only the last line observes the credential that left the machine. The escalation block printed next to the failure said “403 on push is usually OUR stale copy — compare the token’s created_at against the last rotation you were told about,” which sent the reader after a teammate’s token handoff. That is the second time this job has blamed a colleague for a defect on our side (cf. 2026-08-02, where an askpass sniffed a prompt for a project path the prompt never carries). Both share one root: an auth error tells you the credential presented was refused, never that it was the one you intended to present.

Read the failure message literally, and let it partition the causes. GitLab returns 401 HTTP Basic: Access denied for a credential it cannot authenticate, and 403 You are not allowed to upload code for one it authenticates but that lacks push rights. A 403 therefore rules out “our token was revoked” — a revoked token cannot produce it. The distinction was sitting in the error text on 08-05 and we read past it three times.

The fix, and why it is env and not -c:

export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=credential.helper GIT_CONFIG_VALUE_0=””

An empty credential.helper value resets the inherited chain, so askpass becomes the only source. GIT_CONFIG_* applies to every git process in the subshell — including git invoked from inside a script — which -c on one command would miss. It stores no secret anywhere new, so the “one store, one reader” rule is intact.

Generalize past git. The same shape is everywhere we work: PATH (a shim earlier in it wins over the interpreter you configured), Python sys.path, WordPress filter priority, DNS resolver order, ~/.aws/credentials vs AWS_* env vars vs instance metadata, MCP tool name collisions. In every case the honest question is not “is my entry correct?” but “whose entry is consulted first, and does it answer?” Before debugging a resource you configured, print the resolved chain and find your position in it.

Deleting the bad entry is NOT the fix — the fix is the guard, and here is the proof. Cleaning up afterwards, the stale claude-audit entry was removed and the keychain left empty for the host. One unguarded git push --dry-run later, the keychain held a BRAND NEW entry for git.adectra.com under the account oauth2, created that minute — our project token, copied out of .credentials.json and written to a second location by git itself.

That is standard behaviour and it is the whole trap: on a successful authentication git calls credential approve, and every configured helper stores what worked. So a credential helper is not only a reader that outranks you, it is a WRITER that manufactures the next stale entry. Delete the bad row and the very next successful push re-creates one, which will in turn go stale at the next token rotation and shadow you all over again. Manual cleanup is a fix with a half-life.

The same GIT_CONFIG_VALUE_0="" that stops the helper being READ also stops it being WRITTEN — an empty chain has nowhere to store. Verified: three guarded pushes in a row, the keychain still empty afterwards. So the guard is what keeps “one store, one reader” true, and it is also why no extra test was added for “is the keychain clean?” — that assertion is machine-specific, would be meaningless in the cloud runtime, and is already implied by the helper-list assertion that test_credential_source.py makes.

Generalizes to every cache-on-success credential layer: aws configure writing ~/.aws/credentials, docker login writing ~/.docker/config.json, gh auth login, kubectl context tokens, browser password managers. Ask not only “which store answers first?” but “which store is silently recording my answer for next time?” — because that is the one that will be wrong later, long after you have forgotten it exists.

Coda, same day, same shape, caught before it cost anything. Re-verifying the runner’s unknown-flag contract, zsh run_skill_pack_propagation.sh --bogus 2>&1 | head -6; echo $? printed the refusal and then 0 — which reads as “the guard refuses but reports success,” the exact republish_daily.py al defect. It was not. $? after a pipeline is the exit status of the LAST command, so that was head‘s 0, not the script’s. Unpiped, the script returns 2 correctly. Reading a status through a pipe measures the pipe — the identical mistake as cmd | tee || exit N testing tee, and the identical mistake as testing the credential you configured rather than the one git chose. Whenever you read a value, ask which component actually produced it. One near-miss false alarm is cheap; a false alarm filed as a defect would have sent someone to “fix” a guard that was already correct.

Test contract this produced (tools/test_credential_source.py, gated at exit 99 before the push): assert the effective helper list for the repo is EMPTY under the runner’s exact environment; prove the assertion is not vacuous by planting a helper and watching the same check detect it; and confirm askpass returns the project-correct token by digest, never by printing it. A green run now means what wins the chain is what we configured, which is a claim the previous checks could not make.

<!– learning:2026-08-11-an-alarm-a-file-copy-can-switch-off –> August 11, 2026 (from: fleet-uptime-monitor 10:04Z daily run)

An alarm that a file copy can switch off, and it fails in the alarm’s own direction

blitzadmin_site_cache.json had reported 123.2d ⚠️ STALE in every close-out since 2026-07-27. On 2026-08-11 it would have reported 0.1d, and the warning would simply not have appeared. Nothing refreshed it:

` stat birth 2026-08-11T08:38:36Z == mtime, new inode 214067473 <- CREATED this morning inside the file: cached_at 2026-04-07T06:47:47Z <- 126.2 days old membership vs the April 8 backup: 159 sites, 0 added, 0 removed, 0 key changes `

The age check was (now - os.path.getmtime(path)) / 86400 — a measurement of when the container was last written, published as how old the roster is. That check exists because on 2026-07-27 a 111-day-stale cache was found hiding 18 live client sites behind a green result.

The rules.

  1. **Age a data file by its OWN declared timestamp; mtime is corroboration, never the

measurement.** Any roster, cache, baseline or export that a monitor trusts should carry and publish its generation time. Where the two disagree by more than a day the file was copied, restored or touched — report that as its own verdict (MTIME_MASKED_STALE), not as freshness.

  1. A file with no internal timestamp is UNDATED, not fresh. Publish the number *with

its source attached* — 126.2d [content] vs 10.0d [mtime-only] — so a reader can never mistake one kind of age for the other. targets.json has carried no stamp at all for the whole life of this monitor and nobody knew.

  1. Rank failure modes by DIRECTION, not by size. A false alarm is loud and gets fixed.

This one made a number go DOWN and a warning DISAPPEAR: nothing on screen to question, and the report looks cleaner than yesterday’s. Any defect that removes output is worth more scrutiny than one that adds it, because the reader’s natural reaction to a vanished warning is relief.

  1. A lookup that FAILED must never return a number. Missing file, unparseable JSON,

junk in the timestamp field — all UNREADABLE/UNDATED with age = None, never 0. (Sixth member of this family: empty <title> 08-01, unreachable except 07-29, unreadable-counted-clean 08-02, lookup-miss-returns-zero 08-06, UNKNOWNNO_A_RECORD 08-08.)

  1. Read the declared stamp at TOP LEVEL only. A recursive search finds a per-row

last_seen from today and reports the freshest row as the freshness of the file — a measurement attributed to the wrong subject.

A green verdict is only worth its ability to have been red — and that column is what caught the new detector

The same run shipped a new SPLIT-DNS detector and its first production sweep reported ARMED 0 of 16 — not one verdict could have come out red, on a fleet containing its own known positive. It was reading page bodies from the probe engine’s temp file, which the engine reclaims unless asked for keep_body=True, so every response scored “not a read”. Its 83-assertion suite was green and knew nothing, because every section stubbed the step that was broken.

  1. Publish an ARMED count beside every clean count, for every detector. Had this one

printed only HITS 0 it would have closed an owed item with a detector that cannot detect, and the close-out would have been true and worthless. The armed column is not a footnote; it is the difference between evidence and a green pixel.

  1. **A suite that constructs its own inputs proves the predicate and nothing about whether

the predicate is ever handed those bytes.* Every new detector needs at least one assertion that drives real captured bytes through the real call path with only the *transport stubbed. Engine-side data hiding is invisible to any test that builds its own rows. (Third occurrence: read_robots blanking body on 08-09, and this.)

  1. Reading the failure-mode list is not a control against it. Both defects above are

documented in this very file and were read, in order to run the task correctly, roughly ninety minutes before being shipped again. Only a control is a control.

A verifier that counts must first assert what it is counting

The close-out checker recomputes every published number from the raw artifacts. It reported COMING_SOON got=5 claimed=4 * MISMATCH * — the producer was right; the checker had called len() on a dict (pinned/measured/hits/read/total) instead of on its hits list.

  1. The mismatch was luck, and that is the lesson. The dict happens to have five keys.

With four it would have printed got=4 claimed=4 OK — an agreement computed on entirely the wrong object, recorded as verification and never questioned. len() answers for any sized object, and it answers plausibly. Assert the SHAPE before counting, or an agreement is indistinguishable in the report from a correct one.

Smaller, and cheap to avoid

  1. zsh does not word-split unquoted parameter expansions. python3 deep.py $WL

passed a 19-domain list as ONE argv element and the run probed a single nonexistent hostname. It was visible only because the probe refused to score an unresolved host as anything but coverage loss. Use ${=VAR} or an explicit array, and prefer tools that refuse to produce a clean row from a subject they could not resolve.

  1. A redirect is what the visitor gets. Excluding 3xx from a comparison as “not a

read” let two addresses serving a parking stub and two answering a 308 score as AGREEMENT. Reads, hard client-visible failures (TLS/refused) and redirects are all comparable; only a transient 000 is coverage loss, and it must stay excluded so packet loss cannot manufacture a finding.

  1. Keep the defective artifact. The broken sweep’s JSON and log are retained under a

DEFECTIVE name beside the corrected ones. A record that hides its own revision is worth less than one that shows it.

Showing the 13 most recent field lessons of 18. This skill is one of the most-used in the system, so it collects a lesson from almost every run. The complete history ships inside the skill file itself — download any pack and open client-access-checklist.md.


Other skills: ai-search-visibility · boil-the-ocean · client-relationship-cadence · content-agent · content-factory · definitive-article-writer

The full run order is on the skill-pack directory. Every skill here is one task from the Task Library — the library is the catalogue of what can be done; a pack is the subset you install; an agent is who runs it.

Where this sits in the system

A skill is a document. A pack is a folder of documents. Neither one does any work. Work happens when a job runs those skills on a schedule, checks its own output, and keeps its files somewhere it can read them again tomorrow. That is the whole difference between owning skills and having an agent.

  1. Skill — one task, written down to a standard, so an agent can run it without you in the room. There are 239 of them.
  2. PackYOU ARE HERE — those skills bundled into a download you install in one paste.
  3. Agent — a named role with a job description — not a chat window you retype every morning.
  4. Job — a schedule, a QA cycle, and somewhere to keep working files. Miss any of the three and nothing runs twice.
  5. Proof — every finished run written up in public, and the lesson pushed back into the skill.

The map: The System · every asset: Asset Tracker · next door: Asset Tracker.

Scroll to Top