Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lastmod-page-signals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@forkpoint/agent-lighthouse-core": patch
---

Fix two false readings in `machine-discovery/sitemap-lastmod-verifiability`. Dates on nested reviews, comments, questions and answers no longer count as the page's modification time, so a product page with customer reviews and no page date is unverifiable, not divergent. Lastmod values written seconds apart in one generator run now count as one build stamp.
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ Content-page scans do not infer a mount. Origin files, origin probes, feed disco
- **One hour of clock skew is tolerated** before a value counts as
future-dated, so a server a few minutes ahead of the scanner is not reported.

### Page signals exclude contributed dates (2026-10-06)

The sketch says to parse all JSON-LD blocks for `dateModified` and `datePublished`. The audit reads those dates only from nodes that describe the page: top-level nodes, `@graph` members and what they nest. It skips a nested `Review`, `Comment`, `Answer` or `Question`. Those dates record when someone else wrote, not when the page changed. A top-level `Review` is the page itself and keeps its dates.

The trigger was a large retail site. Its product pages publish no modification time, but each one nests customer reviews. The review dates were read as page signals, and five pages with no page date were reported as divergent. They are now unverifiable.

### A build stamp is one run, not one string (2026-10-06)

The claim is "one identical lastmod equal to the last deploy date". The audit compared lastmod strings exactly. A generator that writes the clock per URL spreads one run over seconds, and exact matching saw every value as different. One large retail site stamped 2451 product URLs between 09:57:16 and 09:57:33 on one day. The audit now counts the largest group of sampled values inside a one-hour window. The 90% share and the 3-day recency rules are unchanged. Values hours apart still count as separate dates.

## Deferred

- **Only the first level of a `<sitemapindex>` is walked**, per the shared
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ interface PageSpec {
lastModified?: string;
/** <meta property="article:modified_time">, if the page carries one. */
metaModified?: string;
/** A raw JSON-LD block, served beside any dateModified block. */
jsonLd?: object;
}

function html(spec: PageSpec): string {
Expand All @@ -47,10 +49,13 @@ function html(spec: PageSpec): string {
dateModified: spec.dateModified,
})}</script>`
: "";
const raw = spec.jsonLd
? `<script type="application/ld+json">${JSON.stringify(spec.jsonLd)}</script>`
: "";
const meta = spec.metaModified
? `<meta property="article:modified_time" content="${spec.metaModified}">`
: "";
return `<html><head>${jsonLd}${meta}</head><body><main><p>Copy.</p></main></body></html>`;
return `<html><head>${jsonLd}${raw}${meta}</head><body><main><p>Copy.</p></main></body></html>`;
}

function run(
Expand Down Expand Up @@ -116,6 +121,65 @@ describe("SitemapLastmodVerifiabilityAudit", () => {
expect(result.status).toBe("na");
});

// A product page's customer reviews carry their own datePublished. Those
// dates say when a shopper wrote, not when the page changed; read as page
// signals they turned unverifiable lastmods into "divergent" ones.
describe("contributed dates", () => {
const product = (reviewDaysAgo: number) => ({
"@context": "https://schema.org",
"@type": "Product",
name: "Hoodie",
review: [
{
"@type": "Review",
author: { "@type": "Person", name: "A shopper" },
datePublished: iso(reviewDaysAgo),
},
],
});

it("does not read a nested Review date as a page signal", async () => {
const result = await run(
Array.from({ length: 5 }, (_v, i) => ({
loc: `https://example.com/p-${i}`,
lastmod: iso(5 + i * 9),
jsonLd: product(60 + i * 9),
})),
);
expect(result.status).toBe("warn");
expect(result.found).toContain("0 divergent, 5 unverifiable");
});

it("does not read a nested Review inside an @graph member either", async () => {
const result = await run(
Array.from({ length: 5 }, (_v, i) => ({
loc: `https://example.com/p-${i}`,
lastmod: iso(5 + i * 9),
jsonLd: {
"@context": "https://schema.org",
"@graph": [product(60 + i * 9)],
},
})),
);
expect(result.found).toContain("0 divergent, 5 unverifiable");
});

it("keeps the dates of a page that is itself a Review", async () => {
const result = await run(
Array.from({ length: 5 }, (_v, i) => ({
loc: `https://example.com/r-${i}`,
lastmod: iso(10 + i * 7),
jsonLd: {
"@context": "https://schema.org",
"@type": "Review",
datePublished: iso(10 + i * 7),
},
})),
);
expect(result.status).toBe("pass");
});
});

it("passes when every lastmod matches the page dateModified", async () => {
const result = await run(consistent(5));
expect(result.status).toBe("pass");
Expand Down Expand Up @@ -173,6 +237,28 @@ describe("SitemapLastmodVerifiabilityAudit", () => {
expect(result.message).toContain("build stamp");
});

// A generator that writes the clock per URL spreads one run over seconds.
// Exact-string matching saw every value as distinct and missed the stamp.
it("fails when recent lastmods spread over seconds cover over 90% of sampled URLs", async () => {
const base = Date.now() - DAY;
const urls = Array.from({ length: 10 }, (_v, i) => ({
loc: `https://example.com/s-${i}`,
lastmod: new Date(base + i * 2_000).toISOString(),
}));
const result = await run(urls);
expect(result.status).toBe("fail");
expect(result.message).toContain("one lastmod run");
});

it("does not call lastmods hours apart a build stamp", async () => {
const urls = Array.from({ length: 10 }, (_v, i) => ({
loc: `https://example.com/h-${i}`,
lastmod: new Date(Date.now() - DAY - i * 2 * 3_600_000).toISOString(),
}));
const result = await run(urls);
expect(result.message).not.toContain("build stamp");
});

it("fails when over 20% of sampled URLs diverge from every page signal by more than 7 days", async () => {
const urls = Array.from({ length: 5 }, (_v, i) => ({
loc: `https://example.com/x-${i}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
parseHtml,
extractJsonLd,
extractMetaTags,
allJsonLdNodes,
topLevelJsonLd,
} from "../../parser";
import {
siteSitemapTree,
Expand All @@ -31,6 +31,13 @@ const SAMPLE_SIZE = 6;
const FUTURE_SKEW_MS = 60 * 60 * 1000;
/** One value on this share of the sample is a stamp, not a set of content dates. */
const MODAL_SHARE = 0.9;
/**
* Values this close together are one stamp. A generator that writes the
* clock per URL spreads one run over seconds: one retail site stamped 2451
* product URLs between 09:57:16 and 09:57:33, and exact-string matching saw
* 2451 different dates.
*/
const STAMP_WINDOW_MS = 60 * 60 * 1000;
/** ...but only when that value is this recent, which is what makes it a deploy date. */
const MODAL_RECENCY_DAYS = 3;
/** How far a lastmod may sit from every page signal before it is unsupported. */
Expand All @@ -53,6 +60,56 @@ function parseTime(value: unknown): number | undefined {
return Number.isNaN(parsed) ? undefined : parsed;
}

/**
* Types whose dates belong to someone else's contribution, not to the page.
*
* A product page carries its customers' reviews as `review: [{ "@type":
* "Review", datePublished }]`. Those dates say when a shopper wrote, not
* when the page changed, and on one retail site they turned five pages with
* no modification time at all into "divergent" lastmods.
*/
const CONTRIBUTED_TYPES = new Set([
"Review",
"CriticReview",
"UserReview",
"EmployerReview",
"Comment",
"Answer",
"Question",
]);

function typeNames(node: Record<string, unknown>): string[] {
const raw = node["@type"];
const list = Array.isArray(raw) ? raw : [raw];
return list
.filter((t): t is string => typeof t === "string")
.map((t) => t.replace(/^.*[/:#]/, ""));
}

/**
* The JSON-LD nodes whose dates describe the page: every top-level node and
* `@graph` member, and what they nest, minus nested contributions. A top-level
* Review is the page itself and keeps its dates.
*/
function pageDateNodes(jsonLd: object[]): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [];
const visit = (node: unknown, nested: boolean): void => {
if (Array.isArray(node)) {
for (const item of node) visit(item, nested);
return;
}
if (!isObject(node)) return;
if (nested && typeNames(node).some((t) => CONTRIBUTED_TYPES.has(t))) return;
out.push(node);
for (const [key, value] of Object.entries(node)) {
if (key === "@context") continue;
if (value && typeof value === "object") visit(value, true);
}
};
for (const top of topLevelJsonLd(jsonLd)) visit(top, false);
return out;
}

/** Every modification time the page itself publishes, in no particular order. */
function pageSignals(
headers: Record<string, string>,
Expand All @@ -66,8 +123,7 @@ function pageSignals(
};

add(headers["last-modified"]);
for (const node of allJsonLdNodes(jsonLd)) {
if (!isObject(node)) continue;
for (const node of pageDateNodes(jsonLd)) {
add(node["dateModified"]);
add(node["datePublished"]);
}
Expand Down Expand Up @@ -218,16 +274,29 @@ export class SitemapLastmodVerifiabilityAudit extends Audit {
}
}

// The modal test only looks at URLs we could compare, so a site whose pages
// publish nothing is never accused of stamping builds.
const counts = new Map<string, number>();
for (const entry of sample)
counts.set(entry.lastmod, (counts.get(entry.lastmod) ?? 0) + 1);
const [modalValue, modalCount] = [...counts.entries()].sort(
(a, b) => b[1] - a[1],
)[0] ?? ["", 0];
// The modal test counts the whole sample, compared or not: a stamp is a
// property of the sitemap, and needs no page signal to be seen. It takes
// the largest group of values inside one STAMP_WINDOW_MS window.
const ordered = sample
.map((entry) => ({
lastmod: entry.lastmod,
time: Date.parse(entry.lastmod),
}))
.sort((a, b) => a.time - b.time);
let modalCount = 0;
let modalFirst = "";
let modalLast = "";
for (let start = 0, end = 0; end < ordered.length; end++) {
while (ordered[end]!.time - ordered[start]!.time > STAMP_WINDOW_MS)
start += 1;
if (end - start + 1 > modalCount) {
modalCount = end - start + 1;
modalFirst = ordered[start]!.lastmod;
modalLast = ordered[end]!.lastmod;
}
}
const modalRecent =
(now - Date.parse(modalValue)) / DAY_MS <= MODAL_RECENCY_DAYS;
(now - Date.parse(modalLast)) / DAY_MS <= MODAL_RECENCY_DAYS;
const buildStamp =
sample.length > 1 &&
modalCount / sample.length > MODAL_SHARE &&
Expand All @@ -243,7 +312,7 @@ export class SitemapLastmodVerifiabilityAudit extends Audit {
}
if (buildStamp) {
problems.push(
`${modalCount} of ${sample.length} sampled URLs (${pct(modalCount, sample.length)}%) share the single lastmod ${modalValue}, within ${MODAL_RECENCY_DAYS} days of this scan — the signature of a build stamp rather than a content date`,
`${modalCount} of ${sample.length} sampled URLs (${pct(modalCount, sample.length)}%) share ${modalFirst === modalLast ? `the single lastmod ${modalFirst}` : `one lastmod run, ${modalFirst} to ${modalLast}`}, within ${MODAL_RECENCY_DAYS} days of this scan — the signature of a build stamp rather than a content date`,
);
}
if (compared > 0 && divergent / compared > DIVERGENT_SHARE) {
Expand Down
Loading