{
  "version": 1,
  "note": "Every adjustment made to upstream data before publication. This file is public and is rendered as a changelog at /data/corrections. status: proposed = awaiting sign-off, applied = in effect, rejected = judged not a defect, resolved = adjudicated as a real limitation handled structurally in code rather than by a per-day mask, open = needs a human decision. Only 'applied' entries affect published output. A mask may carry an optional `when` gate ({below:N} / {above:N}); it then fires ONLY on readings that match, so a date-range mask stops overwriting days the source got right and stands down by itself as the source recovers.",
  "actions": {
    "impute": "Replace the value with the source's own trailing median. Correct repair for a broken scraper: the index is an additive sum, so EXCLUDING a source always lowers the score rather than neutralising it.",
    "exclude": "Drop the source and rescale remaining weights. Only valid when a source is being retired, not when it is temporarily broken.",
    "break": "Render a discontinuity in charts without altering the score.",
    "replace": "Substitute an explicit value supplied in `value`."
  },
  "masks": [
    {
      "id": "linkedin-sentinel-2026-07",
      "status": "applied",
      "source": "LinkedIn",
      "from": "2026-06-13",
      "to": null,
      "action": "impute",
      "reason": "sentinel-1000",
      "detectedBy": [
        "V1",
        "V3"
      ],
      "note": "LinkedIn returned its '1,000+' result cap as the literal integer 1000. The source ran 35,000-60,020 for the prior 15 months (methodology.sourceMeans says 47,596; median 47,000; trailing 30-day median 54,000). Effect of NOT correcting: the published index reads Crisis / Tier 1 for weeks on end when the corrected value is Growing / Tier 3 - wrong by two public tiers. GATED 2026-08-31 with when.below = 20,000. CORRECTED 2026-09-01, superseding this entry's original 'the failure is intermittent' reading: the failure is NOT intermittent. Across 2026-06-13..2026-09-01 (81 days) LinkedIn returned 1000 on 58 days, other junk values of 39-3,000 on 10 days, and 13 good readings of 51,000-55,000 - but every one of those 13 good readings falls on or before 2026-07-01. Since 2026-07-02 there have been 62 consecutive bad readings and zero healthy days. The gate remains the right mechanism and is verified sound against real data: the lowest healthy reading ever recorded is 35,000 and broken readings top out at 3,000, so 20,000 separates the two regimes with a wide margin in both directions. Consequence: good days count as real measurements instead of being overwritten by a synthetic constant, and if upstream repairs the scraper the mask stands down by itself with no ledger edit and nobody needing to notice. That automatic-recovery path has had nothing to reinstate for two months and must not be mistaken for evidence that recovery is happening.",
      "evidence": {
        "publishedMean": 30.744,
        "correctedMean": 32.14,
        "window": "2026-07-02..2026-07-30",
        "publishedTier": "Crisis (Tier 1 - Critical Condition)",
        "correctedTier": "Growing (Tier 3 - Growing Strong)"
      },
      "appliedBy": "george@wholewhale.com",
      "appliedAt": "2026-08-31",
      "when": {
        "below": 20000
      },
      "correctedAt": "2026-09-01",
      "correctedBy": "claude-rucbox (WW-1117)"
    },
    {
      "id": "meta-null-2026-06-12",
      "status": "applied",
      "source": "Meta Ads Library - *",
      "from": "2026-06-12",
      "to": "2026-06-12",
      "action": "impute",
      "reason": "null-as-zero",
      "detectedBy": [
        "V4",
        "V9"
      ],
      "note": "All three Meta series returned null. Upstream computeV2WV returns 0 for null and buildSummaries sums it, so the day scored 16.51 against a ~32.6 neighbourhood - suppressing roughly 15.72 points. This is the all-time low across all 453 dates in the dataset and it is an outage, not an event. Any 'worst day on record' claim would be wrong.",
      "appliedBy": "george@wholewhale.com",
      "appliedAt": "2026-07-30"
    },
    {
      "id": "glassdoor-null-2026-06-25",
      "status": "applied",
      "source": "Glassdoor",
      "from": "2026-06-25",
      "to": "2026-06-25",
      "action": "impute",
      "reason": "null-as-zero",
      "detectedBy": [
        "V4"
      ],
      "note": "Glassdoor null, scored as 0, suppressing ~3.18 points.",
      "appliedBy": "george@wholewhale.com",
      "appliedAt": "2026-07-30"
    },
    {
      "id": "glassdoor-null-2026-09",
      "status": "applied",
      "source": "Glassdoor",
      "from": "2026-09-07",
      "to": null,
      "action": "impute",
      "reason": "null-as-zero",
      "detectedBy": [
        "V4"
      ],
      "note": "From 2026-09-07 Glassdoor returns null on most days and junk values (1097, 777, 28) on others. Through 2026-10-06 that is 17 of 30 days. Every one scored as 0 or near 0, which took 1.3-3.6 points off the index and put 15 of those days in Crisis / Tier 1, including the live headline on 2026-10-06 (28.58, percentile 1.2). The source never read below 41,551 before 2026-09-07, and the junk readings top out at 1,097, so a gate at 20,000 separates the two with a wide margin, the same mechanism as linkedin-sentinel-2026-07. Healthy readings pass through, so the mask stands down by itself if upstream recovers. Each bad day carries forward the most recent healthy reading (fill: last-healthy, added the same day): the 30-day median was 121,671, a value cached from 2026-08-15, while every real reading since 2026-09-03 is 115,818-118,017. This predates and is unrelated to the LinkedIn retirement: the 2026-09-28 archive shows the same drops. The glassdoor-flatline-2026-06 break mask was counting these days as covered, so the validator never blocked them.",
      "when": {
        "below": 20000
      },
      "fill": "last-healthy",
      "appliedBy": "george@wholewhale.com",
      "appliedAt": "2026-10-07"
    },
    {
      "id": "meta-nonprofit-null-2026-09-16",
      "status": "applied",
      "source": "Meta Ads Library - nonprofit",
      "from": "2026-09-16",
      "to": "2026-09-16",
      "action": "impute",
      "reason": "null-as-zero",
      "detectedBy": [
        "V4"
      ],
      "note": "Meta nonprofit null on one day, scored as 0, suppressing ~0.85 points (31.29 against a ~32.0 neighbourhood).",
      "appliedBy": "george@wholewhale.com",
      "appliedAt": "2026-10-07"
    },
    {
      "id": "meta-charity-null-2025-04",
      "status": "applied",
      "source": "Meta Ads Library - charity",
      "from": "2025-04-16",
      "to": "2025-04-17",
      "action": "impute",
      "reason": "null-as-zero",
      "detectedBy": [
        "V4"
      ],
      "note": "First two days of the dataset; Meta charity null, scored as 0, suppressing ~3.43 points each. These are the two earliest dates so they anchor the low end of the historical envelope that the tier deciles were fitted to.",
      "appliedBy": "george@wholewhale.com",
      "appliedAt": "2026-07-30"
    },
    {
      "id": "meta-charity-levelshift-2025-08-09",
      "status": "rejected",
      "source": "Meta Ads Library - charity",
      "from": "2025-08-09",
      "to": null,
      "action": null,
      "reason": "level-shift",
      "detectedBy": [
        "V3"
      ],
      "note": "V3 reported a step from ~13,000 to 27,000 on 2025-08-09. The daily series shows no step: charity ramps 12,000 to 27,000 continuously across 2025-08-01..2025-08-09 and keeps climbing to 40,000 by 08-12. Monthly medians confirm a 14-month secular trend rather than a discontinuity: 7,700 (2025-04), 9,600, 13,000, 13,000, 28,000 (2025-08), 22,000, 45,000, 38,000, 48,000, 50,000 (2026-01), pinned at the ceiling thereafter. The other two Meta series move the same direction over the same window (donate 19,200 to 50,000; nonprofit 6,500 to 13,000), which a charity-only query change could not cause. CONSEQUENCE FOR V3: a monotone ramp will eventually exceed any 2x-vs-14-day-trailing-median test, so V3 cannot distinguish a step from a fast trend. Treat a V3 hit as a prompt to plot the series, not as evidence of a defect.",
      "adjudicatedBy": "george@wholewhale.com",
      "adjudicatedAt": "2026-08-31",
      "verdict": "Not a scraper change. V3 false-positived on a sustained trend."
    },
    {
      "id": "meta-ceiling-50000",
      "status": "resolved",
      "source": "Meta Ads Library - *",
      "from": "2025-04-16",
      "to": null,
      "action": null,
      "reason": "value-ceiling",
      "detectedBy": [
        "manual"
      ],
      "note": "Adjudicated 2026-08-31 against the full 485-day series. Decisive evidence: donate has 484 observations, maximum EXACTLY 50,000, and zero readings above it in 16 months, and a real distribution does not stop dead on a round number. Charity sits at exactly 50,000 on 155 of 482 days (32%) with only 3 readings above (all 51,000); a 155-vs-3 cliff on a round number is a cap artifact, not a density. Meta nonprofit never approaches it (modal 6,000-15,000), so the cap belongs to high-volume queries hitting the Ads Library result limit, not to the scraper. All Meta counts are rounded to 1,000, consistent with the Ads Library approximate-count display. EXTENT, which is the material part: charity is pinned on a MAJORITY of days for six consecutive months, 2026-01 (14/19), 02 (23/28), 03 (15/29), 04 (24/29), 05 (27/28), 06 (29/30), and donate reaches a 50,000 median in 2026-06. Charity carries 27.3% of index variance, so the second-largest signal in the index was censored for half of 2026. The index understates real activity across that window, and the 2026-07 fall to 36,000 may be de-saturation rather than a decline. HANDLING: no imputation, because right-censored data cannot be imputed without assuming a tail. The censoring() gate in scripts/build-data.mjs (META_CEILING) already marks any month pinned on 50% or more of days and sets publishable:false on percent changes touching it. That is the correct treatment and it stays.",
      "adjudicatedBy": "george@wholewhale.com",
      "adjudicatedAt": "2026-08-31",
      "verdict": "A result cap. Pinned days are right-censored (true value >= 50,000), not measured."
    },
    {
      "id": "unemployment-dormant",
      "status": "applied",
      "source": "Unemployment",
      "from": "2025-07-02",
      "to": null,
      "action": "exclude",
      "reason": "source-dormant",
      "detectedBy": [
        "V5"
      ],
      "note": "No rows since 2025-07-01 - 13 months. Carries the largest-magnitude weight (-0.003) in the published methodology, so scoring it as absent-equals-zero would silently inflate every score. Excluded from SCORED entirely and disclosed on /methodology rather than quietly dropped. Also carries 16 duplicate (date,source) pairs including 27 rows on 2025-05-01, which the scoring path sums and the display path does not."
    },
    {
      "id": "glassdoor-flatline-2026-06",
      "status": "applied",
      "source": "Glassdoor",
      "from": "2026-06-14",
      "to": null,
      "action": "break",
      "reason": "flatline-stale",
      "detectedBy": [
        "V2"
      ],
      "note": "Glassdoor began repeating cached values in June 2026. Identical-value runs of 11 days (from 2026-06-14), 11 (2026-07-06), 17 (2026-07-22, at 118,415), and 17 and counting (2026-08-15, at 121,671). Across the prior 14 months its median run length was 1 day, with 349 distinct values in 485 days and a maximum run of 7. A 17-day freeze is a stuck scraper, not slow refresh. ACTION IS break, NOT impute, deliberately: unlike the LinkedIn sentinel the repeated value is PLAUSIBLE, since 121,671 sits inside the normal 100,000-155,000 band, so the level is roughly right and only the day-to-day variation is fake. Imputing a trailing median would substitute a near-identical number and fabricate movement that was never measured. Glassdoor carries 9.9% of the index level but only 1.6% of its variance, so the score impact is negligible; the harm is that the index looks more stable than the evidence supports. These days are marked so charts and any claim that Glassdoor moved can be footnoted.",
      "appliedBy": "george@wholewhale.com",
      "appliedAt": "2026-08-31"
    },
    {
      "id": "linkedin-sunset-decision",
      "status": "resolved",
      "source": "LinkedIn",
      "from": "2026-06-13",
      "to": null,
      "action": null,
      "reason": "intermittent-upstream",
      "detectedBy": [
        "V1",
        "V3"
      ],
      "note": "DECISION 2026-08-31: reinstate rather than retire, per George. Retirement was costed and stays available as the fallback - LinkedIn supplies 3.81 points of level (12.0% of the index) but only 0.7% of its variance, and dropping it leaves the daily series correlated 0.9994, so retiring it would cost almost no information. IMPLEMENTATION WARNING retained for the fallback: the exclude action does NOT rescale remaining weights the way this file's glossary claims; it zeroes the contribution. A real retirement means editing SCORED in lib/core.mjs, as Unemployment was. STATED RATIONALE NOW SUPERSEDED (WW-1117, re-derived 2026-09-01): the reinstate decision rested on 'the source is not actually dead - it returned 12 correct readings, so the scraper works intermittently'. That is no longer true as a description of the present. All 13 healthy readings fall between 2026-06-14 and 2026-07-01; the source has produced 62 consecutive bad readings since 2026-07-02, with 0 healthy in the trailing 30 days and 0 in the trailing 14. The decision itself is George's and stands unchanged; what has changed is that its supporting premise has expired, which is the substance the 2026-10-01 review must weigh. NEW EVIDENCE, and the reason rate-limiting should no longer lead: (a) the failure MODE changed at the break. Bad days before 2026-07-02 returned small, varying numbers (350, 358, 398, 563, 707, and 1000 once) consistent with a partial or failed page render; from 2026-07-02 onward 58 of 62 days are exactly 1000, i.e. 93.5% locked to the '1,000+' cap. Those are two different failures, and only the second one is the current state. (b) The break is LinkedIn-specific, not collector-wide: over the same July window Idealist.org returned 29 distinct values in 30 days and VolunteerMatch 30 in 30, so the collector's scheduling and write path are healthy and nothing about the run changed - its timestamps sit at 14:00-14:05 every day, healthy and broken alike. (c) Something is still fetching a real page occasionally: 2026-08-14 returned 39 and 2026-08-18..20 returned 568, 562, 562. A hard-dead selector tends to return nothing or the same thing every time, so a stable read of a page that genuinely shows the cap - an anonymous or logged-out session - fits the evidence better than a selector that broke for good. Both remain possible and only the upstream owner can distinguish them, but they are not equally weighted on this evidence.",
      "openQuestion": "Upstream repair is still unfiled and the source has now been dead for 62 straight days (last good reading 2026-07-01 = 55,000; 0/30 healthy in the trailing month; today 2026-09-01 = 1000). The interleaved signature that originally pointed at rate-limiting ended on 2026-07-02 and has not returned, so throttling should not lead the report. On the 2026-09-01 evidence a permanently logged-out or unauthenticated session is the best-supported single hypothesis, with a permanently broken selector second; only the upstream owner can distinguish them. The collector is a third-party personal repo (rmitsudome/nonprofit-index-data) with no scraper source, so we can report it but not patch it, and no contact has been attempted in two months - that, not the scraper, is the live blocker. Send the upstream report, or hold to the 2026-10-01 review and retire LinkedIn with the 1.1359 rescale if nothing has changed by then?",
      "reviewOn": "2026-10-01",
      "recovery": {
        "measuredOn": "2026-09-01",
        "window": "2026-06-13..2026-09-01",
        "days": 81,
        "healthy": 13,
        "sentinel1000": 58,
        "otherJunk": 10,
        "firstHealthyDay": "2026-06-14",
        "lastHealthyDay": "2026-07-01",
        "lastHealthyValue": 55000,
        "firstBadDayOfCleanBreak": "2026-07-02",
        "consecutiveBadDays": 62,
        "healthyLast30": 0,
        "healthyLast14": 0,
        "healthyMin": 51000,
        "junkMax": 3000,
        "gate": 20000,
        "regimeChange": {
          "note": "The failure mode changed at the break; the pre-break and post-break regimes are different failures.",
          "badValuesBeforeBreak": [
            350,
            358,
            398,
            563,
            707,
            1000
          ],
          "badValuesAfterBreakNot1000": [
            39,
            562,
            562,
            568,
            3000
          ],
          "postBreakDaysAtExactly1000": 58,
          "postBreakDays": 62,
          "postBreakSentinelShare": 0.935
        },
        "sourceIsolation": {
          "note": "Only LinkedIn broke. Distinct daily values over 2026-07-02..2026-07-31 show the collector itself is healthy.",
          "window": "2026-07-02..2026-07-31",
          "distinctValuesPerDay": {
            "LinkedIn": "2/30",
            "Idealist.org": "29/30",
            "VolunteerMatch": "30/30",
            "Glassdoor": "4/30 (separately degraded, see glassdoor-flatline-2026-06)"
          }
        }
      },
      "correctedAt": "2026-09-01",
      "correctedBy": "claude-rucbox (WW-1117)",
      "adjudicatedBy": "claude-rucbox, under george@wholewhale.com standing instruction of 2026-09-28 (retire if no viable replacement)",
      "adjudicatedAt": "2026-09-28",
      "verdict": "Retired. See linkedin-retired. Consecutive bad days had reached 89 (last healthy reading 2026-07-01 = 55,000), September values drifted through round thousands and nulls, and no replacement source passed evaluation."
    },
    {
      "id": "linkedin-retired",
      "status": "resolved",
      "source": "LinkedIn",
      "from": "2025-04-16",
      "to": null,
      "action": null,
      "reason": "source-retired",
      "detectedBy": [
        "V1",
        "V3"
      ],
      "note": "LinkedIn removed from the scored sources on 2026-09-28, the way Unemployment was: SCORED in lib/core.mjs, not the exclude action (which zeroes the term instead of rescaling). The remaining five contributions are multiplied by a fixed 1.1359 (LEVEL_RESCALE), the ratio of the six-source to five-source sum over 2025-04-18..2026-09-27, so the index stays on the scale it was published at. The whole history is restated on the five-source basis. Effect, measured on the 510 days published before the change: mean level 31.944 -> 31.945, daily correlation 0.9987, public tier changed on 29 days. Pre-restatement payloads are archived at /data/archive/2026-09-28-pre-linkedin-retirement/. The linkedin-sentinel-2026-07 mask stays in the ledger as history; it no longer affects output because LinkedIn is not scored. REPLACEMENTS EVALUATED FIRST, none adopted: (1) Indeed Hiring Lab job_postings_tracker (CC-BY-4.0, daily since 2020-02-01, about 10 days behind) - its Community & Social Service series runs OPPOSITE to healthy LinkedIn (log correlation -0.63 daily, -0.73 monthly over 418 healthy days; 43rd of 47 categories), and the all-postings aggregate correlates 0.04, so no Indeed series reproduces what LinkedIn measured; a category that did correlate (Scientific R&D, 0.79) would not be a nonprofit measure. (2) Adzuna API - needs a registered key; returns current counts only, so it cannot be backfilled to July 2026 or calibrated against the pre-June baseline. (3) Indeed publisher/search API - closed to new users; scraping is against its terms. (4) Google Jobs - no public API; third-party SERP resellers scrape Google against its terms and hold no history. (5) LinkedIn official APIs - job-posting APIs are write-side for ATS partners; Talent Insights is an enterprise licence with no count feed; logged-in scraping is ruled out (hiQ v. LinkedIn ended in a breach-of-contract injunction in 2022). (6) BLS CES/JOLTS - free and deep, but monthly, about a month late, and measure employment or all-industry openings rather than nonprofit postings. Reinstating LinkedIn would need upstream (rmitsudome/nonprofit-index-data) to restore healthy readings for a sustained run, and the rescale to be revisited.",
      "adjudicatedBy": "claude-rucbox, under george@wholewhale.com standing instruction of 2026-09-28 (retire if no viable replacement)",
      "adjudicatedAt": "2026-09-28",
      "verdict": "Retired from the index. Restatement applied to all history with a 1.1359 level rescale."
    }
  ]
}
