diff --git a/docs/analysis/2026-09-18-pr-recovery-handoff.md b/docs/analysis/2026-09-18-pr-recovery-handoff.md new file mode 100644 index 0000000000..7f5bc92f63 --- /dev/null +++ b/docs/analysis/2026-09-18-pr-recovery-handoff.md @@ -0,0 +1,68 @@ +# Pull-request recovery handoff + +**Repository:** `Chris0Jeky/Taskdeck` +**Prepared:** 2026-09-18 +**Archival branch base:** `88bb3b4498e975eda17f64dfbd4a975373aa5890` + +## Purpose + +Preserve the outputs of the 2026-09-17 pull-request recovery pass inside GitHub and record what was +actually submitted after the repository moved. This document is a provenance and handoff record; it +does not replace the live pull-request state, CI results, review threads, `STATUS.md`, or the product +masterplan. + +## Remote submission state + +### Frontmatter scalar validation + +The original local proposal is preserved at: + +- `docs/analysis/recovery/2026-09-18-pr-3134-review-fixes.patch` + +The clean replacement PR, #3134, subsequently evolved beyond that proposal and was merged. Its +merged implementation is authoritative. It includes the loader-aligned YAML implicit-scalar +recognition, control-character handling, Unicode validation, regressions, and supporting analysis. +The archived patch is retained only to preserve the investigation trail and must not be replayed over +`main`. + +### Friends-and-family beta runbook + +The original local proposal is preserved at: + +- `docs/analysis/recovery/2026-09-18-pr-3098-review-fixes.patch` + +The applicable corrections were reconciled against the live #3098 branch rather than applying the +stale patch wholesale. The following remote commits were added to +`docs/1325-friends-family-beta`: + +- `6451b6706225429695b7d432fec74d445b5bbb39` — authenticated egress-disclosure procedure, + sole-owner deletion preflight, recurring-backup completion gate, and updated runbook date; +- `dae1c6f7ff0227a1eeab3d79f075295d500e0bee` — read-only SQLite inventory flags and a local + registration-closure probe that cannot be intercepted by Cloudflare Access. + +Pre-existing fixes on the branch, including recurring backup prerequisites and explicit Windows +board selection before capture, were preserved. + +## Archived source material + +- `docs/analysis/recovery/2026-09-17-pr-triage-original.md` is the exact original triage snapshot. + It records the repository state observed during the first pass and is intentionally historical. +- `docs/analysis/recovery/2026-09-18-pr-3134-review-fixes.patch` is the exact original #3134 patch. +- `docs/analysis/recovery/2026-09-18-pr-3098-review-fixes.patch` is the exact original #3098 patch. + +## Integrity + +```text +62f8a3a10aa9dd2880c41830e22a51d58c6305bc7dd19d210e13a564d4fc8db7 2026-09-17-pr-triage-original.md +99990659664381713f5baab4df4fb96c0e0912486c08f832d2744278c09f2df8 2026-09-18-pr-3134-review-fixes.patch +3469208b85c41253c70ac2eae3652abc2eb4a6b38df043e22c726d64bf2f9f92 2026-09-18-pr-3098-review-fixes.patch +``` + +The hashes above are SHA-256 values of the original sandbox files before upload. + +## Verification boundary + +The GitHub commits and file contents are remotely durable once this archival PR is opened. Hosted CI +and fresh code review remain the authority for merge readiness. No claim is made here that an +in-progress workflow has passed, that #3098 has been merged, or that the human participant +walkthrough required by GH-1325 has occurred. diff --git a/docs/analysis/recovery/2026-09-17-pr-triage-original.md b/docs/analysis/recovery/2026-09-17-pr-triage-original.md new file mode 100644 index 0000000000..5f5df6959e --- /dev/null +++ b/docs/analysis/recovery/2026-09-17-pr-triage-original.md @@ -0,0 +1,105 @@ +# Taskdeck pull-request recovery report + +**Repository:** `Chris0Jeky/Taskdeck` +**Observed main:** `b32ec2dbe5e5e7100e1dedd459cc542beca782dd` +**Open pull requests reviewed:** 15 +**Repository mutations made:** none. The connected GitHub surface exposed read/inspection operations but no branch, comment, workflow-rerun, review-resolution, or merge mutation operation; the local runtime also had no authenticated GitHub checkout. + +## Immediate order of work + +1. Finish **#3128**'s exact-head required matrix. If it is terminal green, it is the nearest clean merge candidate. +2. Complete **#3132**'s visual-baseline bootstrap: inspect the fresh 22-image Ubuntu artifact, promote all reviewed images together, rerun Visual Regression, and obtain a fresh review. The supplied five-image contact sheet is not the required artifact. +3. Apply the prepared **#3134** patch, resolve its two review findings, and run fresh exact-head CI. +4. Apply the prepared **#3098** documentation patch, resolve its five current review findings, and rerun docs checks/required CI. +5. Combine or stack **#3131** with **#3111**, rebase onto current main, and run one fresh control-plane qualification. Do not merge #3111 while its invalid numeric-prerelease review finding remains unfixed. +6. Rebase **#3102** after #3128; retain #3128's path/case corrections while replaying the parser/masking changes. +7. After #3132 lands, rebase and rerun **#3100**, **#3090**, and **#3103**. Their old Extended failures include inherited k6 and visual-baseline debt; #3090 and #3103 also require WebKit reproduction on the refreshed head. +8. Rebase and fully requalify **#2838** against the final workflow inventory. +9. Reconcile conflicts on the explicit human-gated PRs **#3130**, **#2931**, and **#3113**, then return them to the maintainer for the required human decision/review. +10. Close **#3067** after #3134 replaces it. + +## Pull-request classification + +| PR | Current classification | Evidence and required next action | +|---|---|---| +| #3128 | Closest merge candidate | Mergeable; Smart CI and Extended CI green; all review threads resolved; required CI was still running. Merge only after that exact-head run is terminal green. Land before #3102. | +| #3132 | Human visual-acceptance gate | Smart CI green; required and Extended runs still in progress. Branch intentionally removes all baselines to generate 22 images. Inspect all 22, commit the complete reviewed set, rerun, and obtain fresh review. Do not promote the older five-image contact sheet. | +| #3134 | Fix prepared; not merge-ready | Mergeable; Smart/Extended green; required run cancelled. Two valid review findings remain: YAML implicit-scalar resolver mismatch and stale control-character assertions. Apply `Taskdeck-PR-3134-review-fixes.patch`, resolve threads, and rerun exact-head CI. | +| #3098 | Fix prepared; not merge-ready | Mergeable and required/Smart CI green, but five unresolved documentation findings remain. Apply `Taskdeck-PR-3098-review-fixes.patch`, resolve threads, run docs governance/link checks, and rerun required CI. | +| #3131 | Green but incorrectly isolated from parent | All three CI workflows green and its review thread is resolved. Its stricter SemVer grammar is the fix required by #3111. Prefer folding/stacking it into #3111 and requalifying once, rather than merging #3111 with a known review blocker. | +| #3111 | Green CI; blocking review remains | All three CI workflows green, but the recorded review demonstrates that `v1.2.3-01` reaches container metadata. Incorporate #3131, rebase, rerun, and obtain a fresh review. | +| #3102 | Conflicted dependency | Currently unmergeable. It touches the same link checker as #3128. Land #3128 first, then rebase and preserve its path/case behavior while replaying parser/masking hardening. | +| #3067 | Superseded and contaminated | Currently unmergeable, 138 commits and 117 changed files. #3134 is the clean replacement. Close after #3134 is qualified and merged. | +| #3100 | Rebase after shared CI repairs | Required and Smart CI green; Extended failed only in inherited k6/visual lanes. Review thread is resolved with warning-lifecycle follow-up tracked separately. Rebase after #3132; restore draft status if repository policy still follows the PR body's stated gate; rerun focused telemetry tests plus full CI. | +| #3090 | Rebase and reproduce | Required and Smart CI green; Extended failed in inherited k6/visual lanes plus WebKit. Review threads are resolved, with colocated-spec coverage explicitly left as a nonblocking residual. Rebase after #3132 and reproduce WebKit before changing product code. | +| #3103 | Rebase and reproduce | Required and Smart CI green; no review threads. Extended failed in inherited k6/visual lanes plus WebKit. Rebase after #3132 and rerun; do not mix unrelated visual/browser repairs into the formatter change unless they reproduce. | +| #2838 | Sound design, stale inventory proof | Mergeable; all three old CI workflows green; no review threads. Security review explicitly requires a current-main refresh because the contract inventories 29 workflow files. Rebase and rerun credential persistence, permission-shape, action-pin, governance, and required suites. | +| #3130 | Conflicted, human-owned | Currently unmergeable; 24 files and 1,564 additions. Body explicitly says human review and human merge. Reconcile current main, rerun frontend/Pages/static-demo proof, then return to the maintainer. | +| #2931 | Conflicted, parked human decision | Currently unmergeable. Control-plane PR is explicitly parked until ADR-0066/J.3(b) is answered; no agent merge. Reconcile only after that decision. | +| #3113 | Conflicted, explicit maintainer gate | Currently unmergeable. Prior exact-head evidence was green, but main has advanced materially. Reconcile and rerun Bash/PowerShell/worktree contract matrices, then require maintainer review. | + +## Prepared patch: #3134 + +File: `Taskdeck-PR-3134-review-fixes.patch` + +Scope: + +- aligns implicit null/boolean/integer/float/timestamp matching with the loader's actual resolver boundaries; +- adds the reviewer's under-match and over-match counterexamples; +- separates Unicode/control-character diagnostics from ordinary whitespace-policy assertions. + +Fresh focused verification: + +- current matcher regression probe: **2 failures**, reproducing `0xF__F` under-match and `0XFF` over-match; +- proposed matcher regression probe: **3/3 passed**; +- deterministic resolver parity: **curated cases plus 250,000 generated scalars passed**; +- patch parser/application against the reconstructed changed fragments: passed; +- full repository test suite: **not claimed**. The connector-fetched local fixture was truncated and was not an executable full checkout; hosted exact-head CI remains required. + +Suggested branch sequence: + +```bash +git switch fix/3006-frontmatter-scalars-clean +git apply --check /path/to/Taskdeck-PR-3134-review-fixes.patch +git apply /path/to/Taskdeck-PR-3134-review-fixes.patch +node --test scripts/check-docs-governance.test.mjs scripts/check-docs-governance.hardening.test.mjs +node --check scripts/check-docs-governance.mjs +git diff --check +``` + +## Prepared patch: #3098 + +File: `Taskdeck-PR-3098-review-fixes.patch` + +Scope: + +- makes the recurring daily/weekly backup schedule a pre-invite gate; +- adds an authenticated same-origin egress-disclosure procedure; +- selects the local board before Windows capture/triage; +- adds a sole-owner preflight before account deletion; +- probes registration closure locally so Cloudflare Access cannot intercept the application-level 403. + +Fresh focused verification: + +- unified patch parses successfully: 2 files, 46 insertions, 12 deletions; +- added egress JavaScript snippet passes `node --check`; +- revised closure-probe command passes `bash -n`; +- full documentation governance/link checks: **not claimed** without a full checkout; run them on the branch after applying the patch. + +Suggested branch sequence: + +```bash +git switch docs/1325-friends-family-beta +git apply --check /path/to/Taskdeck-PR-3098-review-fixes.patch +git apply /path/to/Taskdeck-PR-3098-review-fixes.patch +node scripts/check-docs-governance.mjs +node scripts/check-doc-links.mjs +git diff --check +``` + +## Patch integrity + +```text +99990659664381713f5baab4df4fb96c0e0912486c08f832d2744278c09f2df8 Taskdeck-PR-3134-review-fixes.patch +3469208b85c41253c70ac2eae3652abc2eb4a6b38df043e22c726d64bf2f9f92 Taskdeck-PR-3098-review-fixes.patch +``` diff --git a/docs/analysis/recovery/2026-09-18-pr-3098-review-fixes.patch b/docs/analysis/recovery/2026-09-18-pr-3098-review-fixes.patch new file mode 100644 index 0000000000..28e86e661a --- /dev/null +++ b/docs/analysis/recovery/2026-09-18-pr-3098-review-fixes.patch @@ -0,0 +1,126 @@ +diff --git a/docs/ops/FRIENDS_FAMILY_BETA.md b/docs/ops/FRIENDS_FAMILY_BETA.md +--- a/docs/ops/FRIENDS_FAMILY_BETA.md ++++ b/docs/ops/FRIENDS_FAMILY_BETA.md +@@ -58,14 +58,16 @@ Related safety references: + + ### A1. Complete the host preflight + +-Before contacting the participant, complete sections 1 through 5 of ++Before contacting the participant, complete sections 1 through 5 and section 8 of + [`STAGE1_PRIVATE_INSTANCE_RUNBOOK.md`](STAGE1_PRIVATE_INSTANCE_RUNBOOK.md): + + 1. create the instance secrets and a separate backup key; + 2. start the production Compose stack; + 3. create an encrypted backup; + 4. place the instance behind an identity policy, not a public quick tunnel; +-5. restore the backup into a fresh throwaway volume and record `integrity=ok`. ++5. restore the backup into a fresh throwaway volume and record `integrity=ok`; ++6. configure the daily host backup and weekly off-platform copy/retention jobs from section 8, ++ then record their schedule, destination class, retention rule, and key custodian. + + The hosted path is **not ready to invite** when any of these are true: + +@@ -76,5 +79,7 @@ The hosted path is **not ready to invite** when any of these are true: + - the archive has not restored successfully into a fresh volume; + - the backup or connector key is stored beside the database or archive; ++- the recurring daily backup or twelve-weekly-copy retention jobs are not configured and recorded; ++- the off-platform archive destination or backup-key custodian is not recorded; + - registration is `Open`; + - demo credentials or demo data are being presented as the participant's workspace. + +@@ -201,7 +206,24 @@ Before enabling a live provider: + + 1. follow section 7 of the Stage 1 runbook, including the token ceiling and provider budget alert; + 2. give the participant the disclosure above and record the acknowledgement date privately; +-3. show the participant the instance's `GET /api/privacy/egress` disclosure; ++3. in the participant's own authenticated same-origin browser session, open DevTools → Console and ++ run the following. This keeps the bearer token in that browser and makes the authorized request ++ that ordinary navigation to the raw endpoint cannot make: ++ ++ ~~~javascript ++ const token = localStorage.getItem('taskdeck_token') ++ if (!token) throw new Error('No authenticated Taskdeck session token was found') ++ const response = await fetch('/api/privacy/egress', { ++ headers: { Authorization: 'Bearer ' + token }, ++ }) ++ const disclosure = await response.json() ++ console.log(response.status, disclosure) ++ ~~~ ++ ++ Require HTTP 200 and let the participant read the returned disclosure before continuing. Run ++ this only in their own Taskdeck tab; never paste the token into a shared note, message, issue, or ++ maintainer-controlled console. If the request is not 200, leave the live provider disabled and ++ resolve the access/session problem first; + 4. use a provider key created for this instance, never a personal all-purpose key; + 5. disable the provider and recreate the stack if the agreed spending threshold is breached. + +@@ -244,7 +267,8 @@ Use this path for a Windows 10/11 x64 participant who should own the data locally + > **Try the review gate** + > + > 1. Create a blank synthetic board. +-> 2. Open **Inbox** and capture: `Please create a card called local family beta check.` ++> 2. Open **Inbox**, select that blank synthetic board in the board picker, and then capture: ++> `Please create a card called local family beta check.` + > 3. Select the capture and choose **Start Triage** (shown as **Ask AI** in Paper mode). + > 4. Open **Review**, inspect the proposal, and choose **Approve**. + > 5. Confirm the board is still unchanged. +@@ -391,8 +415,16 @@ This cohort uses manual, consented observation. Do not claim invisible telemetry. + + Keep the downloaded file private. It is account-scoped and does not include the full shared-board + column/card/label/comment tree; do not send it to the maintainer unless the participant explicitly + consents to that transfer. +-3. Do not treat account deletion as a maintainer action. If the participant requests deletion, they ++3. Before requesting deletion, reconcile every board the participant owns. For each such board, the ++ participant opens **Workspace → Settings → Access**, grants the maintainer `Owner` access (or ++ promotes the maintainer's existing access from `Editor`/`Admin` to `Owner`), then reopens the ++ access list and verifies that another owner is present. The account-deletion service refuses to ++ proceed while the participant is the sole owner of any board. Do not rely on the deletion request ++ itself as the ownership inventory. If every participant-owned board cannot be given another ++ verified owner, stop and record deletion as blocked; remove perimeter and board access where ++ possible, but do not claim that the account was deleted. ++4. Do not treat account deletion as a maintainer action. If the participant requests deletion, they + must perform the supported authenticated request from their own session, using their current + password and the exact confirmation phrase `DELETE MY ACCOUNT` at `POST /api/account/delete`. + The participant can perform that request from their own authenticated same-origin browser session: +@@ -422,10 +456,10 @@ This cohort uses manual, consented observation. Do not claim invisible telemetry. + up to + 12 weekly encrypted off-platform backups (about 90 days). Tell the participant these retention + limits before deletion and do not claim that all linked records or backups disappeared. +-4. Take a final encrypted backup only when its retention was disclosed and agreed. +-5. Stop exposure or keep registration `Closed`; do not reopen registration while an unused invite is ++5. Take a final encrypted backup only when its retention was disclosed and agreed. ++6. Stop exposure or keep registration `Closed`; do not reopen registration while an unused invite is + still valid. +-6. Record the exit reason and whether the participant would return after a named change. ++7. Record the exit reason and whether the participant would return after a named change. + + ### Normal end of a local Windows trial + +diff --git a/docs/ops/STAGE1_PRIVATE_INSTANCE_RUNBOOK.md b/docs/ops/STAGE1_PRIVATE_INSTANCE_RUNBOOK.md +--- a/docs/ops/STAGE1_PRIVATE_INSTANCE_RUNBOOK.md ++++ b/docs/ops/STAGE1_PRIVATE_INSTANCE_RUNBOOK.md +@@ -196,16 +196,20 @@ Also reconcile every unconsumed, unexpired registration invite in the second que + collaborator over a channel you already trust; they register. + 3. **Close registration:** set `TASKDECK_REGISTRATION_MODE=Closed` in `deploy/.env`, re-run the + `up -d` command from step 2 (or the two-file command from step 7 if live providers are already on) +- so the container is recreated, then prove it with a **syntactically valid** throwaway registration +- that also carries the invite code minted in 6.2 (an empty body only proves model validation, which +- answers 400 in every mode): ++ so the container is recreated. Then, from the host, prove the application state against the local ++ bind with a **syntactically valid** throwaway registration that also carries the invite code ++ minted in 6.2. The local probe reaches Taskdeck directly, so Cloudflare Access cannot intercept it ++ before the application returns the required closure response. An empty body only proves model ++ validation, which answers 400 in every mode: + + ```bash +- curl -s -w '\n%{http_code}\n' -X POST https:///api/auth/register -H 'Content-Type: application/json' \ ++ curl -s -w '\n%{http_code}\n' -X POST http://localhost:8080/api/auth/register -H 'Content-Type: application/json' \ + -d '{"username":"closure-probe","email":"closure-probe@example.invalid","password":"Closure-Probe-Passw0rd!","inviteCode":""}' + ``` + + Required: HTTP **403** and the body text `Registration is closed by this Taskdeck instance.` + (`RegistrationPolicyService.RegistrationClosedMessage`). `InviteOnly` answers a different forbidden + message (`A valid registration invite is required.`) or, with a live invite, succeeds; either means the + container was not recreated with `Closed`, and the invite can still create a third account. ++ Section 4's outside-the-policy test separately proves the identity perimeter; do not substitute an ++ unauthenticated request through Cloudflare Access for this application-level closure proof. diff --git a/docs/analysis/recovery/2026-09-18-pr-3134-review-fixes.patch b/docs/analysis/recovery/2026-09-18-pr-3134-review-fixes.patch new file mode 100644 index 0000000000..5089702189 --- /dev/null +++ b/docs/analysis/recovery/2026-09-18-pr-3134-review-fixes.patch @@ -0,0 +1,178 @@ +--- a/scripts/check-docs-governance.mjs ++++ b/scripts/check-docs-governance.mjs +@@ -9,13 +9,13 @@ + export const CI_CONTROL_RULE_PATH = '.claude/rules/ci-control.md' + const FORBIDDEN_SCALAR_CONTROL = /[\u0000-\u001F\u007F-\u009F]/u + +-const YAML_NULL_SCALAR = /^(?:~|null)$/i +-const YAML_BOOLEAN_SCALAR = /^(?:true|false)$/i +-const YAML_INTEGER_SCALAR = /^[+-]?(?:0b[01](?:_?[01])*|0o[0-7](?:_?[0-7])*|0x[0-9a-f](?:_?[0-9a-f])*|[0-9](?:_?[0-9])*)$/i +-const YAML_FLOAT_SCALAR = /^[+-]?(?:(?:[0-9](?:_?[0-9])*)?\.[0-9](?:_?[0-9])*(?:e[+-]?[0-9](?:_?[0-9])*)?|[0-9](?:_?[0-9])*\.(?:[0-9](?:_?[0-9])*)?(?:e[+-]?[0-9](?:_?[0-9])*)?|[0-9](?:_?[0-9])*e[+-]?[0-9](?:_?[0-9])*)$/i +-const YAML_NON_FINITE_FLOAT_SCALAR = /^[+-]?\.(?:inf|nan)$/i +-const YAML_DATE_SCALAR = /^\d{4}-\d{2}-\d{2}$/ +-const YAML_TIMESTAMP_SCALAR = /^\d{4}-\d{2}-\d{2}(?:[Tt]|[ \t]+)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:[ \t]*(?:[Zz]|[+-]\d{1,2}(?::?\d{2})?))?$/ ++// Match the default YAML schema's exact implicit scalar spellings. Deliberately avoid broad ++// case-insensitive patterns: mixed-case booleans/nulls and uppercase numeric prefixes are strings. ++const YAML_NULL_SCALAR = /^(?:~|null|Null|NULL)$/ ++const YAML_BOOLEAN_SCALAR = /^(?:true|True|TRUE|false|False|FALSE)$/ ++const YAML_FLOAT_SCALAR = /^(?:[-+]?(?:[0-9][0-9_]*)(?:\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/ ++const YAML_DATE_SCALAR = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/ ++const YAML_TIMESTAMP_SCALAR = /^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}(?:[Tt]|[ \t]+)[0-9]{1,2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]*)?(?:[ \t]*(?:Z|[-+][0-9]{1,2}(?::[0-9]{2})?))?$/ + + const requiredDocs = [ + 'docs/STATUS.md', +@@ -102,13 +102,79 @@ + : { value, error: null, quoted } + } + ++function isAsciiDecimalDigit(char) { ++ return char >= '0' && char <= '9' ++} ++ ++function isAsciiOctalDigit(char) { ++ return char >= '0' && char <= '7' ++} ++ ++function isAsciiHexDigit(char) { ++ return ( ++ isAsciiDecimalDigit(char) || ++ (char >= 'A' && char <= 'F') || ++ (char >= 'a' && char <= 'f') ++ ) ++} ++ ++function isYamlImplicitIntegerScalar(value) { ++ const max = value.length ++ let index = 0 ++ let hasDigits = false ++ let char = value[index] ++ ++ if (max === 0) return false ++ ++ if (char === '-' || char === '+') { ++ index += 1 ++ char = value[index] ++ } ++ ++ if (char === '0') { ++ if (index + 1 === max) return true ++ index += 1 ++ char = value[index] ++ ++ let isValidDigit = null ++ if (char === 'b') isValidDigit = (candidate) => candidate === '0' || candidate === '1' ++ else if (char === 'o') isValidDigit = isAsciiOctalDigit ++ else if (char === 'x') isValidDigit = isAsciiHexDigit ++ ++ if (isValidDigit !== null) { ++ index += 1 ++ for (; index < max; index += 1) { ++ char = value[index] ++ if (char === '_') continue ++ if (!isValidDigit(char)) return false ++ hasDigits = true ++ } ++ return hasDigits && char !== '_' ++ } ++ } ++ ++ if (char === '_') return false ++ ++ for (; index < max; index += 1) { ++ char = value[index] ++ if (char === '_') continue ++ if (!isAsciiDecimalDigit(char)) return false ++ hasDigits = true ++ } ++ ++ return hasDigits && char !== '_' ++} ++ ++function isYamlImplicitFloatScalar(value) { ++ return YAML_FLOAT_SCALAR.test(value) && !value.endsWith('_') ++} ++ + function isYamlImplicitNonStringScalar(value) { + return ( + YAML_NULL_SCALAR.test(value) || + YAML_BOOLEAN_SCALAR.test(value) || +- YAML_INTEGER_SCALAR.test(value) || +- YAML_FLOAT_SCALAR.test(value) || +- YAML_NON_FINITE_FLOAT_SCALAR.test(value) || ++ isYamlImplicitIntegerScalar(value) || ++ isYamlImplicitFloatScalar(value) || + YAML_DATE_SCALAR.test(value) || + YAML_TIMESTAMP_SCALAR.test(value) + ) +--- a/scripts/check-docs-governance.hardening.test.mjs ++++ b/scripts/check-docs-governance.hardening.test.mjs +@@ -59,7 +59,10 @@ + '0b1010', + '0o17', + '0xFF', ++ '0xF__F', ++ '1__0', + '1.5', ++ '1.5__0', + '.5', + '5.', + '1e3', +@@ -71,6 +74,8 @@ + '.NaN', + '2026-09-17', + '2026-09-17T12:34:56Z', ++ '2026-09-17T12:34:56.', ++ '2026-9-7T1:02:03.', + '2026-09-17 12:34:56 +01:00', + ] + +@@ -91,7 +96,7 @@ + } + }) + +-test('does not overmatch ordinary path-like strings', () => { ++test('does not overmatch loader-resolved strings or ordinary path-like strings', () => { + for (const path of [ + '2026-09-17-notes.md', + '123/notes.md', +@@ -100,6 +105,16 @@ + '1e3/results.md', + '.nan.md', + 'nullish', ++ '0XFF', ++ '0B10', ++ '0O7', ++ '+.nAn', ++ '.nAn', ++ '1e1_0', ++ 'nUlL', ++ 'tRuE', ++ '2026-09-17T12:34:56z', ++ '2026-09-17T12:34:56+0130', + ]) { + const errors = collectControlPathMirrorErrors( + JSON.stringify({ controlPaths: [path] }), +--- a/scripts/check-docs-governance.test.mjs ++++ b/scripts/check-docs-governance.test.mjs +@@ -301,7 +301,7 @@ + assert.deepEqual(collectControlPathMirrorErrors(policy, ruleFixture(['**/.npmrc'])), []) + }) + +-for (const path of [' ci/**', 'ci/** ', '\tci/**', 'ci/**\n']) { ++for (const path of [' ci/**', 'ci/** ']) { + test(`rejects policy whitespace rather than normalizing away the mismatch: ${JSON.stringify(path)}`, () => { + const result = parsePolicyControlPaths(JSON.stringify({ controlPaths: [path] })) + assert.equal(result.controlPaths.length, 0) +@@ -309,4 +309,12 @@ + }) + } + ++for (const path of ['\tci/**', 'ci/**\n']) { ++ test(`rejects policy control characters before whitespace validation: ${JSON.stringify(path)}`, () => { ++ const result = parsePolicyControlPaths(JSON.stringify({ controlPaths: [path] })) ++ assert.equal(result.controlPaths.length, 0) ++ assert.ok(result.errors.some((error) => /control/.test(error)), result.errors.join(' | ')) ++ }) ++} ++ + test('decodes supported quoting in path values instead of comparing escape source text', () => {