diff --git a/scripts/__baseline__/README.md b/scripts/__baseline__/README.md
index 28ddb66..bfeb53b 100644
--- a/scripts/__baseline__/README.md
+++ b/scripts/__baseline__/README.md
@@ -5,33 +5,20 @@ agent layer existed. `skill-check.mjs` asserts against it on every run.
Regenerating it is a deliberate act, and the reason belongs here.
-## OUTSTANDING — the HTML baselines contain data that no longer exists
+## 2026-09-20 — the three HTML baselines that held invented people were recaptured
-**Six checks fail on purpose. Do not regenerate these baselines to clear them.**
-
- Hired History paints the same styled elements, in the same order
- Hired History shows the same words
- Hired History added only identity wrappers
- analytics: paints the same styled elements, in the same order
- analytics: shows the same words
- candidates: shows the same words
-
-The first five are the `DEMO_FILL` removal, described immediately below. The
-sixth is the final-selection queue and has its own dated entry further down.
+Settled. The section that stood here said six checks failed on purpose and
+listed the evidence; this is that debt being paid.
`hiringRecords.js` used to pad the hires list with five invented people
(`DEMO_FILL`) so Hired History read as a history rather than as three rows. The
-padding applied to Analytics too, so "total hires" counted eight where the
-database held three. It has been removed: hires now come only from `staff`.
+padding reached Analytics too, where "total hires" counted eight against a
+database holding three. It was removed, and because these baselines render with
+queries disabled, the padding was *all* they contained. Candidates separately
+became the final-selection queue, which changed what its empty state says.
-`hired-history.pre-migration.html` and `analytics.pre-migration.html` were
-captured **while the padding was in effect** — and because these baselines render
-with queries disabled, the padding is *all* they contain. The Hired History
-baseline is 31,453 characters holding all five invented names; the genuine empty
-state is 11,947 and holds none.
-
-**Verified before leaving them failing**, so the drift is known rather than
-assumed. Tag counts, baseline → now:
+Verified before regenerating, so the drift was known rather than assumed. Tag
+counts for Hired History, baseline -> now:
0 five table rows of people who were never hired
13
@@ -39,25 +26,57 @@ assumed. Tag counts, baseline → now:
7
8
-Every difference is content that was fabricated. No styling, ordering or
-structural rule changed.
+Every difference was content that had been fabricated. Analytics lost 269 class
+attributes and 238 words to the same cause. Candidates gained exactly one class
+— the description paragraph under "Nobody is awaiting a decision". No styling
+rule, ordering rule or structural rule changed on any of the three.
-**Why they are not regenerated yet.** Two of them — `added only identity
-wrappers` and `carries node identity in the DOM` — exist to prove the UI node
-tree migration added exactly two ``s and nothing else. That proof needs the
-baseline to be *pre*-migration markup. Recapturing now would write *post*-
-migration markup into a file named `pre-migration`, and the check would compare
-the current render against itself and pass forever without proving anything.
+**Recaptured and renamed**, because a file called `pre-migration` that holds
+post-`dca1842` markup is a lie in the filename:
-So the debt is held until the migration work lands, at which point both files
-are recaptured together and this section is replaced by a dated entry saying so.
+ hired-history.pre-migration.html -> hired-history.render.html
+ analytics.pre-migration.html -> analytics.render.html
+ candidates.pre-migration.html -> candidates.render.html
+
+`activity-page`, `candidates-analysis`, `control-center`, `positions` and
+`talent-pool` still hold genuine pre-migration markup, still pass, and keep the
+name that says so. The `PAGES` table in `skill-check.mjs` now carries each
+baseline's FILENAME rather than deriving one suffix for all of them.
+
+**The migration proof was not thrown away with the baseline.** One check —
+`Hired History added only identity wrappers` — compared tag tallies to assert
+that migrating the page added exactly two `
`s and changed nothing else.
+That was true, and it was checkable only while the DATA was frozen as well: the
+predicate subtracts one whole render from another, so removing the invented
+hires moved every count and the arithmetic stopped describing wrappers at all.
+Recapturing would not have rescued it — with the baseline equal to the render
+the delta is zero, and a predicate demanding two can never hold again. Left in
+place it would have stayed red for a new reason, which is worse than failing
+for the old one.
+
+It is replaced by three checks that read the render itself and need no frozen
+file, so they keep holding as the page's content changes:
+
+ Hired History wraps exactly the node types registered to wrap
+ Hired History identities are unique and name composed nodes
+ Hired History identity wrappers carry no styling
+
+Together these say what the tally said — `UiTreeRenderer` encloses a type
+registered `wrap: true` in `
`, every other type takes the
+attributes on its own root element, and a wrapper contributes identity and no
+styling. Hired History composes five nodes; two are registered to wrap and
+produce the two divs, two carry their identity on their own `
`, and
+`hired-extensions-top` is an extension slot that renders nothing while no skill
+is attached to it. The second check therefore asserts uniqueness and no strays
+rather than one identity per composed node — a one-to-one rule would be
+asserting that every extension slot is always filled.
+
+`carries node identity in the DOM` was described here as needing pre-migration
+markup. It does not: it reads only the live render, names `chronology` and
+`records` directly, and is unaffected by any of this. It still passes.
+
+`npm test` is 1693/1693.
-**One related fix was made rather than deferred.** `HiredChronology` used to
-`return null` with no hires, which took the `chronology` node identity out of the
-DOM with it — so with an honest empty dataset the section could not be addressed
-by Owliver or the layout editor at all, and the page said nothing where it most
-needed to. It now renders an empty state inside the section, which the section
-keeps. `carries node identity in the DOM` passes again because of it.
## 2026-08-27 — the seed gained the three statuses nothing exercised
@@ -156,8 +175,9 @@ The new stage filter options cost nothing here. `FilterSelect` is a Radix
`SelectValue` renders empty on the server — the baseline contains neither the
old option labels nor the new ones.
-**Why it is not regenerated.** The same reason as the five above:
-`candidates.pre-migration.html` is load-bearing for the UI node tree proof, and
-recapturing it now would write post-migration markup into a file named
-`pre-migration`. It is held until the migration work lands and all of these are
-recaptured together.
+**Regenerated on 2026-09-20**, with the other two — see the entry at the top of
+this file. It was held until then because `candidates.pre-migration.html` was
+load-bearing for the UI node tree proof, and recapturing it earlier would have
+written post-migration markup into a file named `pre-migration`. The file is now
+`candidates.render.html` and the proof it was holding up has been replaced by
+three checks that read the render directly.
diff --git a/scripts/__baseline__/analytics.pre-migration.html b/scripts/__baseline__/analytics.pre-migration.html
deleted file mode 100644
index 6fc7555..0000000
--- a/scripts/__baseline__/analytics.pre-migration.html
+++ /dev/null
@@ -1 +0,0 @@
-
Hiring performance Across the workspace
Hiring funnel Applied → Screened → Shortlisted → Interview → Hired No applications on file yet. The funnel appears once the first candidate applies.
Hiring trend Cumulative hires by month Not enough history for a trend
All 5 hires closed in Jul. A month-on-month line appears once hiring spans a second month.
Department performance 4 departments compared Department Performance & Hiring Flow Interactive pipeline mapping AI score quality & hire velocity across departments
Top Department
Security (95)
Active Units
4 Departments
Pipeline Source
Talent Engine 100% AI Verified Engine
Total Hires
5 staff filled
Hiring Outcomes
Verified Quality Impact 100% Active Placement
Department Performance Units (4) Click any department card for detailed staff roster
Position performance 5 roles filled Hires, quality, speed and review outcome by role Role Hires Avg score Avg days Reviewed Rating Guest Relations Lead 1 90 2d 0/1 Pending
Event Coordinator 1 91 3d 0/1 Pending
Operations Supervisor 1 87 2d 0/1 Pending
Concierge Lead 1 88 2d 0/1 Pending
Lead Security Officer 1 95 1d 0/1 Pending
Hiring efficiency 2d median time-to-hire 2dmedian
80% of roles close within 48 hours.
Lead Security Officer
1 hire
Guest Relations Lead
1 hire
2d
Operations Supervisor
1 hire
2d
2d
Guest Relations Lead
1 hire
2d
Operations Supervisor
1 hire
2d
2d
AI hiring insights 3 findings Security is hiring the strongest candidates
1 hire at an average score of 95, against 90 across the workspace.
Event Coordinator takes longest to fill
3 days on average against a median of 2. 1 hire on that record.
5 hires have no client review
Quality of hire is measured on the AI score alone until a review lands. Chasing these closes the loop on outcomes.
\ No newline at end of file
diff --git a/scripts/__baseline__/analytics.render.html b/scripts/__baseline__/analytics.render.html
new file mode 100644
index 0000000..6c2a288
--- /dev/null
+++ b/scripts/__baseline__/analytics.render.html
@@ -0,0 +1 @@
+
Hiring performance Across the workspace
Hiring funnel Applied → Screened → Shortlisted → Interview → Hired No applications on file yet. The funnel appears once the first candidate applies.
Hiring trend Cumulative hires by month Not enough history for a trend
No hires recorded yet. Hiring volume over time appears here once the first role closes.
Department performance 0 departments compared
Position performance 0 roles filled Hires, quality, speed and review outcome by role Role Hires Avg score Avg days Reviewed Rating
Hiring efficiency Not enough dated hires —median
Velocity appears once hires carry an application date.
No role has enough dated hires to measure velocity yet.
No role has enough dated hires to measure velocity yet.
There is not enough hiring on file to draw a finding from yet. Insights appear as applications, hires and reviews accumulate.
\ No newline at end of file
diff --git a/scripts/__baseline__/candidates.pre-migration.html b/scripts/__baseline__/candidates.pre-migration.html
deleted file mode 100644
index f53d3ba..0000000
--- a/scripts/__baseline__/candidates.pre-migration.html
+++ /dev/null
@@ -1 +0,0 @@
-No candidates match your filters
\ No newline at end of file
diff --git a/scripts/__baseline__/candidates.render.html b/scripts/__baseline__/candidates.render.html
new file mode 100644
index 0000000..6f8a5dd
--- /dev/null
+++ b/scripts/__baseline__/candidates.render.html
@@ -0,0 +1 @@
+Nobody is awaiting a decision
Candidates arrive here once their interview is completed, and leave once they are hired or declined.
\ No newline at end of file
diff --git a/scripts/__baseline__/hired-history.pre-migration.html b/scripts/__baseline__/hired-history.pre-migration.html
deleted file mode 100644
index be54cb4..0000000
--- a/scripts/__baseline__/hired-history.pre-migration.html
+++ /dev/null
@@ -1 +0,0 @@
-
Recent hiring timeline Last 5 of 5 Sophia Chen
Guest Relations Lead
Jul 22, 2026
Oliver Bennett
Event Coordinator
Jul 20, 2026
Aaliyah Patel
Operations Supervisor
Jul 18, 2026
Lucas Wright
Concierge Lead
Jul 15, 2026
Elena Rostova
Lead Security Officer
Jul 12, 2026
Hired candidate records 5 people Everyone hired, with position, client, score and time-to-hire Candidate Position Company Department Hired AI score Time to hire Status SC Sophia Chen
sophia.chen@email.com
Guest Relations Lead — Front Desk Jul 22, 2026 90 2d Hired
OB Oliver Bennett
oliver.b@email.com
Event Coordinator — Event Manager Jul 20, 2026 91 3d Hired
AP Aaliyah Patel
aaliyah.p@email.com
Operations Supervisor — Housekeeping Jul 18, 2026 87 2d Hired
LW Lucas Wright
lucas.w@email.com
Concierge Lead — Front Desk Jul 15, 2026 88 2d Hired
ER Elena Rostova
elena.r@email.com
Lead Security Officer — Security Jul 12, 2026 95 1d Hired
Showing 1–5 of 5
Rows 10 25 50 100 Select a row to open the full record.
\ No newline at end of file
diff --git a/scripts/__baseline__/hired-history.render.html b/scripts/__baseline__/hired-history.render.html
new file mode 100644
index 0000000..a994e83
--- /dev/null
+++ b/scripts/__baseline__/hired-history.render.html
@@ -0,0 +1 @@
+
Recent hiring timeline Nothing yet No hires yet The timeline fills in as candidates are hired for a position.
Hired candidate records 0 people No hires recorded yet Hires appear here as positions close.
Select a row to open the full record.
\ No newline at end of file
diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs
index 45c0fef..1890304 100644
--- a/scripts/skill-check.mjs
+++ b/scripts/skill-check.mjs
@@ -8829,22 +8829,69 @@ console.log('\n── Candidates vs Talent Pool ──');
const classes = (html) => (html.match(/class="[^"]*"/g) || []).join('\n');
const words = (html) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
- const tally = (html) => (html.match(/<\/?[a-z][a-z0-9-]*/gi) || [])
- .map((t) => t.toLowerCase())
- .reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {});
-
const hiredNow = stripControls(await draw('/src/pages/admin/HiredHistory.jsx', 'hired-history', '/admin/hired'));
- const hiredBefore = readFileSync(join(ROOT, 'scripts/__baseline__/hired-history.pre-migration.html'), 'utf8');
+ const hiredBefore = readFileSync(join(ROOT, 'scripts/__baseline__/hired-history.render.html'), 'utf8');
record('Hired History paints the same styled elements, in the same order',
classes(hiredNow) === classes(hiredBefore));
record('Hired History shows the same words', words(hiredNow) === words(hiredBefore));
- record('Hired History added only identity wrappers', (() => {
- const b = tally(hiredBefore);
- const n = tally(hiredNow);
- const changed = [...new Set([...Object.keys(b), ...Object.keys(n)])].filter((t) => (b[t] || 0) !== (n[t] || 0));
- return { pass: changed.join(',') === '`s and changed
+ * nothing else. That was true, and it was only checkable while the page's
+ * DATA was frozen too: the comparison subtracts one whole render from
+ * another, so removing the invented hires moved every count and the
+ * arithmetic stopped describing the wrappers at all. Recapturing does not
+ * rescue it either — with the baseline equal to the render the tag delta is
+ * zero, and a predicate demanding a delta of two can never hold again.
+ *
+ * So the property is asserted directly instead of inferred from a
+ * subtraction. `UiTreeRenderer` gives a node its identity one of two ways:
+ * a type registered `wrap: true` is enclosed in `
`, and
+ * every other type takes the attributes on its own root element. The three
+ * checks below say exactly that, and they keep saying it as the page's
+ * content changes — which the tally could not.
+ */
+ const hiredComposed = compMod.composePage('hired-history', ctx5).tree;
+ const hiredNodes = nodeMod5.walk(hiredComposed);
+ const hiredWrapped = hiredNodes.filter((n) => reg5.get(n.type)?.wrap === true);
+ /* The attribute list of every `
` that carries an identity. */
+ const hiredWrappers = [...hiredNow.matchAll(/
]*\bdata-ui-node="[^"]+"[^>]*)>/g)]
+ .map((m) => m[1]);
+ const hiredIds = [...hiredNow.matchAll(/data-ui-node="([^"]+)"/g)].map((m) => m[1]);
+
+ record('Hired History wraps exactly the node types registered to wrap',
+ hiredWrappers.length === hiredWrapped.length,
+ `${hiredWrappers.length} wrapper div(s) for ${hiredWrapped.length} wrapping type(s): `
+ + `${hiredWrapped.map((n) => n.id).join(', ') || 'none'}`);
+
+ /* Unique, and every one a node the page actually composed.
+ NOT "one per composed node": an extension slot with no skills attached
+ renders nothing at all, which is correct and is why `hired-extensions-top`
+ has no identity here. Asserting a one-to-one mapping would be asserting
+ that every slot is always filled. What must hold is that nothing is
+ addressed twice and nothing is addressed that the page did not compose —
+ either would break the editor's and Owliver's ability to name a node. */
+ const hiredComposedIds = new Set(hiredNodes.map((n) => n.id));
+ const hiredDuplicated = hiredIds.filter((id, i) => hiredIds.indexOf(id) !== i);
+ const hiredStrays = hiredIds.filter((id) => !hiredComposedIds.has(id));
+ const hiredSilent = hiredNodes.filter((n) => !hiredIds.includes(n.id)).map((n) => `${n.id} (${n.type})`);
+
+ record('Hired History identities are unique and name composed nodes',
+ hiredDuplicated.length === 0 && hiredStrays.length === 0,
+ `${hiredIds.length} of ${hiredNodes.length} composed node(s) addressed`
+ + `${hiredSilent.length ? `; rendering nothing: ${hiredSilent.join(', ')}` : ''}`
+ + `${hiredDuplicated.length ? `; DUPLICATED: ${hiredDuplicated.join(', ')}` : ''}`
+ + `${hiredStrays.length ? `; STRAY: ${hiredStrays.join(', ')}` : ''}`);
+
+ /* Identity, not presentation. A wrapper that picked up a class would be
+ styling the page from the tree, which is the one thing wrapping a node
+ must never do — it is why `nodeAttrs` emits `data-` attributes only. */
+ record('Hired History identity wrappers carry no styling',
+ hiredWrappers.every((attrs) => !/\bclass=/.test(attrs)),
+ hiredWrappers.map((a) => a.trim()).join(' | ') || 'no wrappers');
record('Hired History carries node identity in the DOM', (() => {
const ids = [...hiredNow.matchAll(/data-ui-node="([^"]+)"/g)].map((m) => m[1]);
return ids.includes('chronology') && ids.includes('records');
@@ -9073,11 +9120,17 @@ console.log('\n── Candidates vs Talent Pool ──');
const words = (h) => h.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const PAGES = [
- ['candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates'],
- ['talent-pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool'],
- ['analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics'],
- ['candidates-analysis', '/src/pages/admin/CandidatesAnalysis.jsx', '/admin/candidates-analysis', 'candidates-analysis'],
- ['control-center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center'],
+ /* The fourth column is the baseline FILE, not a stem. Three pages were
+ recaptured after the final-selection work changed what they render and
+ are named `.render.html`; the other two still hold genuine
+ pre-migration markup and keep the name that says so. Deriving the
+ filename from the page would force both sets to share one suffix, and
+ whichever suffix won would be a lie about half of them. */
+ ['candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates.render.html'],
+ ['talent-pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool.pre-migration.html'],
+ ['analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics.render.html'],
+ ['candidates-analysis', '/src/pages/admin/CandidatesAnalysis.jsx', '/admin/candidates-analysis', 'candidates-analysis.pre-migration.html'],
+ ['control-center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center.pre-migration.html'],
];
for (const [page, modulePath, route, baseline] of PAGES) {
@@ -9089,7 +9142,7 @@ console.log('\n── Candidates vs Talent Pool ──');
React.createElement(UiEditingProvider, { page },
React.createElement(Page))))
));
- const before = readFileSync(join(ROOT, `scripts/__baseline__/${baseline}.pre-migration.html`), 'utf8');
+ const before = readFileSync(join(ROOT, `scripts/__baseline__/${baseline}`), 'utf8');
/**
* A migration may INSERT a wrapper class — grouping three siblings under