Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,16 @@ jobs:
workspace: '@rootsystem/forensics'
changed: ${{ needs.changes.outputs.forensics }}
steps:
# Full history, not the default shallow clone. The forensics build derives
# `dateModified` for each route from the last commit that touched its
# sources (sites/forensics/scripts/content-modified.mjs). On a depth-1
# clone `git log` reports the shallow boundary instead, so every page
# would claim it was modified on the day of the most recent push --
# a freshness signal that is wrong rather than merely absent.
- uses: actions/checkout@v5
if: matrix.changed == 'true'
with:
fetch-depth: 0

- uses: jdx/mise-action@v3
if: matrix.changed == 'true'
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
.claude/

# Build Output
# Route dates derived from git history; rewritten on every build.
sites/forensics/src/generated/
out/
dist/
.astro/
Expand Down
8 changes: 4 additions & 4 deletions sites/forensics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"dev": "node scripts/content-modified.mjs && astro dev",
"build": "node scripts/content-modified.mjs && astro build",
"preview": "astro preview",
"check": "astro check",
"deploy": "astro build && wrangler deploy"
"check": "node scripts/content-modified.mjs && astro check",
"deploy": "yarn build && wrangler deploy"
},
"dependencies": {
"@astrojs/cloudflare": "^14.1.5",
Expand Down
25 changes: 25 additions & 0 deletions sites/forensics/public/.well-known/ai.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# forensics.rootsystem.com — AI crawler and usage policy
#
# Crawling is open, and deliberately so. This property exists to be found and
# read by counsel evaluating an expert, and an answer engine summarising it
# accurately is doing the same job the site does. The permissive posture here
# matches robots.txt, which is Allow: / for every agent.
#
# ai.txt is a convention rather than a standard. It is stated here so the
# position is explicit rather than inferred from the absence of a file.

User-agent: *
Allow: /

# Training on the published pages is permitted. Everything served here is
# marketing and credential material intended for publication.
Allow-training: yes

# Nothing under /api/ is public. Case intake carries live matter detail written
# before a conflict check has run, and it is not crawlable, not indexable and
# not available for training.
Disallow: /api/

Contact: partners@rootsystem.com
Sitemap: https://forensics.rootsystem.com/sitemap-index.xml
Summary: https://forensics.rootsystem.com/ai/summary.json
107 changes: 107 additions & 0 deletions sites/forensics/scripts/content-modified.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Derives a `dateModified` per route from git history, before the Astro build.
*
* Why git rather than a hand-kept date: a date written by hand is a claim
* nobody verifies, and the first time it goes stale it is worse than absent --
* answer engines weight freshness, and a page that says it was updated last
* week when it was not is the kind of small untruth this property avoids
* everywhere else. Git already records when a page's inputs changed, so the
* date cannot drift from the work.
*
* ASSUMPTION, stated because it is the weak point: a route's inputs are its
* page file plus the copy deck it renders. The deck is one file for the whole
* property, so a copy edit to any section moves the date on every route that
* reads the deck. That is coarse but not false -- schema.org defines
* dateModified as when the work was most recently modified, and a change to a
* page's source inputs is a modification of that page whether or not the
* visible bytes moved. It errs toward "something that feeds this page changed",
* never toward a date nothing supports.
*
* The output is generated, gitignored, and rewritten on every build. It is
* imported as plain JSON so nothing in the page pipeline shells out to git at
* render time.
*
* FALLBACK BEHAVIOUR MATTERS IN CI. `actions/checkout` clones shallow by
* default, and `git log` on a shallow clone reports the shallow boundary rather
* than the real last-touch commit. The deploy workflow sets `fetch-depth: 0`
* for that reason. If git is unavailable or a path has no history at all, the
* route falls back to the HEAD commit date, and finally to the build time --
* a build never fails over this, it just gets less precise.
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))
const siteRoot = resolve(here, '..')
const repoRoot = resolve(siteRoot, '../..')
const outputPath = resolve(siteRoot, 'src/generated/content-modified.json')

/**
* Routes and the source paths that determine their content.
*
* Paths are repo-relative because that is what `git log` wants. Every route
* includes the copy deck: the deck is where the words live, and a route whose
* words changed has been modified regardless of whether its .astro file did.
*/
const COPY_DECK = 'sites/forensics/src/copy/landing.ts'

const ROUTES = {
'/': ['sites/forensics/src/pages/index.astro', COPY_DECK],
'/method/': ['sites/forensics/src/pages/method.astro', COPY_DECK],
'/matters/': ['sites/forensics/src/pages/matters.astro', COPY_DECK],
'/engagements/': ['sites/forensics/src/pages/engagements.astro', COPY_DECK],
'/experts/': ['sites/forensics/src/pages/experts/index.astro', COPY_DECK],
'/experts/[slug]/': ['sites/forensics/src/pages/experts/[slug].astro', COPY_DECK],
'/scope/': ['sites/forensics/src/pages/scope.astro', COPY_DECK],
// The privacy policy is shared with sites/www and lives outside this site.
'/privacy/': ['sites/forensics/src/pages/privacy.astro', 'legal/privacy-policy.ts'],
}

/** Runs git and returns trimmed stdout, or null if git is unusable here. */
function git(args) {
try {
return execFileSync('git', args, {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
} catch {
return null
}
}

/**
* The most recent commit date across a set of paths, as an ISO 8601 string.
*
* `--` separates paths from revisions so a path that looks like a ref cannot be
* misread. `%cI` is the committer date in strict ISO 8601, which is what
* schema.org wants and what answer engines parse without guessing a timezone.
*/
function lastModified(paths) {
const result = git(['log', '-1', '--format=%cI', '--', ...paths])
return result || null
}

const headDate = git(['log', '-1', '--format=%cI']) || new Date().toISOString()

const modified = Object.fromEntries(
Object.entries(ROUTES).map(([route, paths]) => [
route,
lastModified(paths) || headDate,
]),
)

// `generatedAt` is recorded for debugging a stale build, not for publication --
// nothing renders it. Publishing a build timestamp as dateModified is exactly
// the untruth this file exists to prevent.
const payload = { generatedAt: new Date().toISOString(), routes: modified }

mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`)

console.log(
`content-modified: wrote ${Object.keys(modified).length} route dates ` +
`(head ${headDate})`,
)
31 changes: 31 additions & 0 deletions sites/forensics/src/copy/landing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,22 @@ const copy: Landing = landingSchema.parse({
note: 'With Process Metrix/Insitec Measurement Systems, SEMATECH and Sandia National Laboratories.',
},
],
// Canonical profiles elsewhere, feeding `sameAs` in the Person markup.
// Both were verified before being published here: the ORCID record
// resolves to Rob Jacques through the public API, and the LinkedIn
// vanity URL is the one the profile copy was written from.
//
// ORCID is listed as the resolvable https:// form rather than the bare
// identifier, because `sameAs` takes URLs and a bare ORCID is not one.
//
// No Google Scholar entry: a Scholar profile is a researcher page a
// person claims at scholar.google.com, and there is no reason to create
// one around a single conference paper from 2000. If the practice starts
// publishing, that changes.
sameAs: [
'https://www.linkedin.com/in/robertjacques1/',
'https://orcid.org/0009-0005-3159-3392',
],
},
],

Expand All @@ -612,6 +628,21 @@ const copy: Landing = landingSchema.parse({
// answer (NOTIFY_CC in wrangler.jsonc).
responseTime: 'We reply within one business day.',
},

// Structured-data identity. Nothing here renders as visible copy.
//
// EMPTY AND WAITING ON REAL URLS. `sameAs` should list canonical profiles
// that are demonstrably this practice -- a LinkedIn company page, a
// Crunchbase entry, a professional-society listing. It is the Knowledge Graph
// disambiguation signal and the cheapest trust signal available, but every
// entry has to be a page that exists and that the practice controls: a
// `sameAs` pointing at something that is not the same entity merges two
// entities in the index, which is worse than saying nothing.
//
// The same field exists per person on each expert profile below.
organization: {
sameAs: [],
},
})

export default copy
28 changes: 28 additions & 0 deletions sites/forensics/src/copy/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,16 @@ export const landingSchema = z.object({
publications: z.array(
z.object({ title: z.string(), where: z.string(), note: z.string() }),
),
// Canonical profiles for this person elsewhere -- LinkedIn, ORCID,
// Google Scholar, a licensing board's public lookup. Feeds `sameAs` in
// the Person markup, which is how a search engine resolves a name to
// one person rather than to everyone who shares it. For an expert
// witness that disambiguation is also a vetting convenience: counsel
// searching the name should land on the record, not on a namesake.
//
// Empty by default and emitted only when populated. Nothing goes in
// here that the practice does not control and cannot verify.
sameAs: z.array(z.url()).default([]),
}),
)
.min(1),
Expand Down Expand Up @@ -335,6 +345,24 @@ export const landingSchema = z.object({
footer: z.string(),
responseTime: z.string(),
}),

// Entity identity, for structured data only -- nothing here renders as copy.
//
// `sameAs` is the Knowledge Graph disambiguation signal: the list of other
// canonical URLs that are demonstrably the same entity, which is what lets a
// search engine resolve this practice to one thing instead of guessing. A GEO
// audit on 2026-09-04 scored Social Trust 0/5 and Academic Trust 0/5 on the
// absence of it, and it is the cheapest E-E-A-T signal available.
//
// Defaults to empty and is emitted only when populated. Every URL here must
// be a profile that exists and is controlled by the practice -- a `sameAs`
// pointing at a page that is not the same entity is worse than none, because
// it merges two entities in the index.
organization: z
.object({
sameAs: z.array(z.url()).default([]),
})
.default({ sameAs: [] }),
})

export type Landing = z.infer<typeof landingSchema>
Expand Down
Loading
Loading