Skip to content

Take the license-key reader from tag 5.0.0 - #1794

Open
marcin-kordas-hoc wants to merge 34 commits into
developfrom
hf-307-license-key-5-0-0
Open

marcin-kordas-hoc wants to merge 34 commits into
developfrom
hf-307-license-key-5-0-0

Conversation

@marcin-kordas-hoc

@marcin-kordas-hoc marcin-kordas-hoc commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Takes vendor/entitlement-key-reader from license-key tag 5.0.0. The copy was 4.0.1.

Why this is its own PR

#1728 stands without it. This only matters if keys will be issued by a 5.0.0 generator. It is based on the #1728 branch, so it shows one commit; when #1728 merges, retarget it to develop.

What 5.0.0 changes for a product that copies the reader

  • The checksum now covers the key's sentences as well as its payload. Editing or removing a sentence invalidates the key. Rewrapping, putting it on one line and Unicode composition do not.
  • A block on its own is no longer a key.
  • "4.x keys are no longer valid; there is no compatibility path."

So with the 4.0.1 copy a key from a 5.0.0 generator reads as invalid and every cell evaluates to #LIC!, and a key from a 4.x generator is invalid for 5.0.0. The copy has to match the generator that issues the keys.

What changes here

  • The 13 files of the directory are re-taken from the tag. Five differ from 4.0.1: extractKeyData.ts, readLicense.ts, types.ts and the two guides.
  • The one declared divergence is unchanged: the same Object.keys(current as object) cast for TypeScript 4.0.8, on the same line of text (now line 146). upstream.json and PROVENANCE.md carry the new tag, commit and date.
  • A comment on the key identity no longer says a bare block is the same license.
  • No engine logic changes.

Checked

  • npm run check:license-key-parser-drift: the directory is identical to the tag apart from the declared cast.
  • tsc and lint clean; 512 suites (6413 tests) pass; the bundles build.
  • Paired tests: same branch name in hyperformula-tests. They do not compile against the 4.0.1 copy.

Open

  • Which generator version issues keys for the 3.5 release. If it is not 5.0.0, do not merge this.
  • Docs are untouched. The guide shows only the old key shape, and a customer who keeps only part of a new key would now get an invalid key. Whether the guide should say "pass the whole key" is a call for the docs owner.

Note

Medium Risk
Breaking entitlement-key format with no backward compatibility; merging before the key generator is on 5.0.0 would invalidate customer keys or reject 4.x keys.

Overview
Re-vendors the entitlement-key reader from handsontable/license-key 4.0.1 → 5.0.0, with upstream.json / PROVENANCE.md updated and the same single TypeScript 4.0.8 cast in extractKeyData.ts.

The 5.0.0 format change (DEV-3253) is the substance: the SHA-512 checksum is now over canonical prose + payload, not the payload alone. extractKeyData.ts adds canonicalizeProse, computeChecksum, rejects empty prose, bare [...] blocks, and non-whitespace after the block; types.ts / docs expand unreadable accordingly. Integrators must pass the full key (no trimming inside the block); README gains “Where users keep the key” and stricter AGENTS.md rules.

HyperFormula engine wiring is unchanged; only a keyIdentityOf comment in licenseKeyValidator.ts reflects that identity still uses the trailing checksum. 4.x keys and 5.0.0 keys are not interchangeable—this copy must match whichever generator issues production keys.

Reviewed by Cursor Bugbot for commit 5b72a24. Bugbot is set up for automated code reviews on this repo. Configure here.

marcin-kordas-hoc and others added 30 commits August 11, 2026 08:31
Implements tasks 1.1-1.5 of the HF-307 spec (rev 2.4): the license
entitlement model, the capability registry, and gate B in the
interpreter's FUNCTION_CALL evaluation. Gate A (existing key-validity
check) is untouched.

New:
- src/license/LicenseEntitlement.ts: FeatureId, LicenseExpiry,
  LicenseEntitlement, unrestrictedEntitlement()
- src/license/capabilities.ts: CapabilityGrant, CAPABILITY_TABLE
  (placeholder: every built-in under one 'core' token, refreshed from
  the static function registry on every CapabilityRegistry
  construction rather than at module load, since src/index.ts
  registers built-in plugins only after Config/Interpreter have
  already been evaluated)
- src/license/CapabilityRegistry.ts: resolve() (transitive,
  cycle-safe `implies` expansion), capabilityOf(), allowsFunction(),
  allowsFeature()

Modified:
- src/Config.ts: licenseCapabilities/isLicenseGateActive/
  capabilityRegistry added to the existing privatePool WeakMap, never
  exposed through getConfig()
- src/interpreter/Interpreter.ts: gate B added after gate A in the
  FUNCTION_CALL case, with a custom-function exemption predicate
  (capabilityOf() === undefined) and alias canonicalization so an
  alias gates identically to its canonical name
- src/error-message.ts: ErrorMessage.LicenseCapability

Decision deltas applied (Kuba, 2026-08-10): D1 (custom_functions
token dropped this release, FeatureId.CustomFunctions kept as
reserved vocabulary) and D3 (fail-closed + silent: an entitlement
with no recognized token grants only core, without a message,
warning, or diagnostics getter - unrestrictedEntitlement() no longer
covers that case). D2 and D4 are out of scope for this PR.

PR 1 ships without a real license-key payload adapter (PR 3), so
Config always resolves an unrestricted entitlement for now - gate B
is a correct, independently-testable no-op in production until PR 3
lands.

Full PR description, verification notes, and drafted private-repo
tests (no credentials available this session for hyperformula-tests):
marcin-kb/handoffs/hf-307-pr1-tests/

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
…ants

No grant in the table ever named another token, and the table is documented as
holding its entries fully expanded, so the recursive walk resolved nothing that
a flat pass over the entitlement's own tokens does not. Removing the field
takes the queue, the visited set and the cycle guard with it.

Repeating a token still grants what it grants once, which the paired suite now
pins directly rather than through implication.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kuba Sękowski's review of #1728 (src/license/CapabilityRegistry.ts:16): a
single `unrestricted: boolean` cannot express a key that grants every
function but not every feature, or the reverse - both axes could only be
granted together or neither. ResolvedCapabilities.functions and .features
are now each `ReadonlySet<T> | 'all'`, and allowsFunction/allowsFeature
check their own axis independently.

`unrestricted` carries a fail-open invariant (resolveLicense: a missing,
invalid or expired key resolves to an unrestricted entitlement, by design -
gate A already stops evaluation on a bad key, so gate B must not turn a
lapsed legacy key into a throwing API). Splitting it opens a matching
fail-closed trap: an edit that sets 'all' on one axis and forgets the other
silently narrows a working install instead of leaking anything. Both axes
are set in the same object literal in CapabilityRegistry.resolve() for
exactly that reason, and the paired test commit pins each axis separately
so a one-axis regression fails only one test.

Does not touch LicenseEntitlement.unrestricted, a distinct field on the
pre-resolution entitlement that this change leaves alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
> **Update 2026-09-22 — review round with @sequba.** The sections below
are the PR as written
> before that round, kept for the reasoning they record. Where a
sentence no longer matches the
> code it is corrected inline and marked, rather than rewritten away.

### Context

HF-307 (feature packages / entitlement gating). This is PR 2 of 4:
guards on the public API. Stacked on hf-307-entitlement-gating-pr1
(#1728) - depends on
`CapabilityRegistry`/`allowsFeature`/`FeatureId`/`Config.licenseCapabilities`
from that PR. Mechanical, public-repo-only, no key-format knowledge
needed.

New:
- `src/errors.ts` - `LicenseCapabilityMissingError`, mirroring the
existing ~30 error classes. Now also exported from the public entrypoint
(`src/index.ts`) - see finding 6.
- `src/HyperFormula.ts` - private `ensureCapability(feature:
FeatureId)`, mirroring `ensureEvaluationIsNotSuspended`: a single
`isLicenseGateActive` boolean read on the fast path, then
`allowsFeature` against the resolved entitlement.
- `src/BuildEngineFactory.ts` - private static
`ensureNamedExpressionsCapability(config, namedExpressions)`, the
build-time counterpart (task 2.3).
- ~~`src/CrudOperations.ts` - public `isCutClipboard()`~~ **removed
22.09**: the reviewer accepted the cut-paste bypass, so the second
capability check it fed is gone and the wrapper with it. See the
correction under finding 6.

Modified: `ensureCapability` wired as the **first statement** (before
argument validation - a license error should beat a type error) in the
~20 methods from spec §05 task 2.2:
- NamedExpressions: `addNamedExpression`, `changeNamedExpression`,
`removeNamedExpression`
- Clipboard: `copy`, `cut`, `paste` — Clipboard only. (The conditional
**Crud** check described under finding 6 was removed 22.09.)
- Crud: `addRows`, `removeRows`, `addColumns`, `removeColumns`,
`moveCells`, `moveRows`, `moveColumns`, `addSheet`, `removeSheet`,
`clearSheet`, `setSheetContent`, `renameSheet`, `setCellContents`,
`swapRowIndexes`, `setRowOrder`, `swapColumnIndexes`, `setColumnOrder`
- UndoRedo: `undo`, `redo`
- Batching: `batch`, `suspendEvaluation` — but **not**
`resumeEvaluation`, see finding 4 below

`CustomFunctions` is not gated anywhere, per HF-307 decision D1.
`ImportExport` has no methods to gate yet (HF-107).

**Where the line is drawn** (written into the `ensureCapability` JSDoc
so a later change has to move it on purpose):
- **Gated** — mutations that create value: the sheet, the clipboard, the
undo history, the named-expression set.
- **Not gated** — reads (`getCellValue`, `listNamedExpressions`,
`getAllNamedExpressionsSerialized`, the `isItPossibleTo*` predicates)
and teardown that only ever *removes* state (`clearClipboard`,
`clearUndoStack`, `clearRedoStack`, `destroy`). Gating cleanup would let
a restricted entitlement strand an integration mid-teardown while giving
a licensee nothing. This mirrors gate B, which blocks *calling* a
function rather than *reading* an already-computed value. There is a
test pinning this, because "we chose not to gate cleanup" is otherwise
indistinguishable from "we forgot these" — which is exactly how the
reordering gap below survived the first pass, and how the cut/paste gap
in finding 6 survived this one.

**Open question resolved:** the handoff left "is
`getAllNamedExpressions` gated or read-only-exempt?" open. There's no
such method today (it's `listNamedExpressions`, `getNamedExpression`,
`getAllNamedExpressionsSerialized`) - I left all three ungated. This
follows gate B's own precedent: it blocks *calling* a function, not
*reading* a cell's already-computed value, so a restricted entitlement
can still see named expressions that already exist. Flagging for
reviewers in case the intended scope was broader.

Task 2.3: `BuildEngineFactory.ensureNamedExpressionsCapability` runs the
same `allowsFeature(FeatureId.NamedExpressions)` check when the
`namedExpressions` argument to
`buildFromSheets`/`buildFromSheet`/`buildEmpty` (what
`buildFromArray`/`buildFromSheets`/`buildEmpty` resolve to) is
non-empty; an empty list is never checked. Deliberately **not** applied
to `rebuildWithConfig` - that path re-serializes named expressions an
already-built instance was already allowed to create (e.g. on
`updateConfig()`), rather than accepting them fresh from a caller, so a
later entitlement change must not retroactively break an existing
instance's own state.

This ships without a real license-key payload adapter (PR 3), so every
entitlement `Config` can produce today is unrestricted -
`ensureCapability` and the build-time check are correct,
independently-testable no-ops in production until that adapter lands,
exactly like gate B in PR 1.

### Self-review findings (after the PR was first opened)

Recording these in the open rather than quietly amending, since several
are things a reviewer should get to disagree with. Findings 4 and 5 came
from Cursor Bugbot's inline comments and were confirmed here before
fixing. Finding 6 came from an independent spec-to-ship review run
against this PR specifically after it had already been open for review
(a 5-dimension multi-agent pass: guard completeness,
test-quality-by-mutation, the shipped build artifact, docs/CHANGELOG
accuracy, and merge mechanics - each finding adversarially re-verified
before being reported).

**1. The Crud gate was bypassable — fixed in `8a26e2be7`.** The first
pass gated `moveRows`/`moveColumns` but missed four structurally
identical methods: `swapRowIndexes`, `setRowOrder`, `swapColumnIndexes`,
`setColumnOrder`. A restricted entitlement with no Crud grant could
still permute every row and column in a sheet, which defeats the gate
for a whole class of structural mutation. All four are now gated *in
their own right* rather than relying on the `swap*` method that the
`set*Order` pair delegates to — otherwise `set*Order`'s own
`mappingFromOrder` validation would run first and a type error would
beat the license error, violating task 2.2's first-statement rule.

**2. `ensureCapability` checks gate B only, never gate A — deliberate,
and now an explicit invariant.** Today a missing or invalid key yields
`#LIC!` in cells while the CRUD API keeps working; this PR preserves
that exactly. The risk is forward-looking: if PR 3's key adapter
resolves an invalid key to a *restricted* entitlement rather than an
unrestricted one, every gated method here starts throwing and that
becomes a silent breaking API change. This PR is what makes that
reachable, so the invariant is documented at the guard itself.
**Reviewers: worth confirming this is the intended split before PR 3
lands.**

**3. Known evidence gap in the task 2.3 tests.** The
`BuildEngineFactory` gate is exercised by calling the private static
directly, because the public path (`buildFromArray(sheet, config,
namedExpressions)` → throws) *cannot* be reached today — the factories
construct their own `Config`, and every `Config` currently resolves
unrestricted. So Codecov's 100% patch coverage overstates the evidence
for that one check: the integration path is unproven until PR 3 makes a
restricted `Config` constructible. Flagging rather than papering over
it.

**4. Gating `resumeEvaluation` could brick an engine — fixed in
`c29a9ba40`.** Raised by Bugbot, confirmed. `resumeEvaluation` is the
*only* exit from a suspended engine, and `_evaluationSuspended` survives
`rebuildWithConfig`. So: suspend while Batching is granted →
`updateConfig` produces an entitlement without Batching → the instance
is permanently unusable, because every read throws
`EvaluationSuspendedError` and the sole recovery path threw
`LicenseCapabilityMissingError`. It is now ungated. `suspendEvaluation`
and `batch` stay gated — those are the entry points that make the
feature worth licensing, and if you cannot *enter* batching you can
never extract value from it. Generalised into a rule on
`ensureCapability` so it does not get re-added: **a capability check
must never be reachable only on the way *out* of a state it let the
caller into** — the same reasoning that leaves teardown ungated.

**5. One of PR 1's tests was vacuous — fixed in the paired tests PR.**
Also raised by Bugbot. PR 1's custom-function-exemption test built the
sheet with `'=CUSTOMFUNC()'` already in it, so the formula was evaluated
during `buildFromArray` while the entitlement was still unrestricted;
`restrictEngine` then changed nothing that `getCellValue` would re-read,
and the assertion passed against a cached value. I verified this rather
than assuming it — deleting the exemption from `Interpreter.ts` outright
left the test **still passing**. It now writes the formula via
`setCellContents` *after* restricting, matching the shape its sibling
tests already had, and the same mutation now correctly fails it. The two
new `resumeEvaluation` regression tests were verified the same way
(re-adding the gate fails them).

**6. `paste` was a hard-gating bypass, plus a packaging gap and a
documentation gap — found by an independent spec-to-ship review, all
fixed.**
- **Bypass (HIGH).** `paste()` checked only `FeatureId.Clipboard`, but
`CrudOperations.paste()` dispatches to `moveCells()` internally when the
clipboard holds a cut - the exact cell-relocating mutation the public
`moveCells()` requires **Crud** for. A `Clipboard`-only entitlement
could reach it via `cut()`+`paste()`, with no Crud grant ever checked.
Reproduced live (the move fully executed, no exception, under a
Clipboard-only entitlement) before fixing. Fixed by adding
`CrudOperations.isCutClipboard()` and additionally gating `paste()` on
`Crud` when the clipboard holds a cut - still checked before argument
validation. **Reversed 22.09 at the reviewer's request** ("Drop this
check. We accept this bypass"): granting the clipboard is taken to grant
what the clipboard does, so this route is deliberately not gated on Crud
as well. The check, the wrapper and the stale `@throws` tag are gone;
the paired spec now pins the acceptance - a clipboard-only entitlement
relocates the cell - so the hole cannot be closed again by accident.
- **Packaging gap (HIGH).** `LicenseCapabilityMissingError` was never
imported/exported from `src/index.ts`, so it was unreachable from the
package's public entrypoint: `import {LicenseCapabilityMissingError}
from 'hyperformula'` failed to compile (`TS2614`), the default-export
static form was `undefined`, and no deep import worked either (the
`exports` map in `package.json` only defines `.` and the i18n subpaths).
A consumer had no supported way to catch this error by type - the
existing test suite never caught it because it imports the class
directly from `src/errors`, bypassing the public entrypoint entirely.
Fixed by exporting it alongside the other ~30 error classes already
there.
- **Documentation gap (MEDIUM).** None of the 27 gated methods carried a
`@throws [[LicenseCapabilityMissingError]]` tag, breaking this file's
own established per-method `@throws` convention (every *other* exception
type already gets one). The class-level `@see` list on
`LicenseCapabilityMissingError` itself also wrongly named
`resumeEvaluation` (which finding 4 deliberately excludes) and omitted
all 17 Crud methods. Fixed: all 27 gated instance methods plus the 3
static build factories now carry the tag, and the `@see` list is
corrected and complete.

The same review also generalised finding coverage on the test side:
mutation-testing every one of the 27 `ensureCapability` call sites found
17 with zero regression protection of their own (the "one test per
feature group" strategy only pinned the group representative) - see the
paired tests PR for the added coverage.

*Process note, since it generalises:* Bugbot's findings 4 and 5 were
missed by the automated review tier that scrapes them (a regex looking
for `Severity: Medium` against Bugbot's actual `**Medium Severity**`),
which reported a clean PASS. Finding 6 was missed by both Bugbot and a
prior manual review pass (which had only caught the narrower,
single-method version of the test-coverage gap). Worth reading Bugbot's
inline comments directly rather than trusting an aggregator, and worth
an independent adversarial pass even after a PR looks clean.

### How did you test your changes?

- `tsc --noEmit` and `tsc -p tsconfig.test.json`: clean.
- `eslint` on all changed files: 0 errors (pre-existing warning
categories only, none introduced by this change).
- Manual verification against the built engine (ts-node scratch script,
not committed): every gated method throws
`LicenseCapabilityMissingError` under a simulated restricted
entitlement, an unrestricted entitlement (today's default) is
unaffected, and `buildFromArray` with named expressions under the
default unrestricted entitlement does not throw. Re-verified against a
freshly rebuilt `commonjs` package for finding 6 specifically (the
cut+paste fix and the `index.ts` export).
- Private test suite pushed:
[hyperformula-tests#31](handsontable/hyperformula-tests#31)
- `unit/license/public-api-guards.spec.ts` (42 tests: one positive + one
negative per feature group, a validation-order test, the build-time
bypass pair, five covering the reordering gap, one pinning the
ungated-cleanup rule, two for the `resumeEvaluation` deadlock, one
dedicated throw test per previously-group-only-covered method (15 of
them), the cut/paste bypass control+regression pairs, and one proving
`LicenseCapabilityMissingError` is reachable from the public entrypoint)
plus fixes to PR 1's `unit/licence.spec.ts` (the test-helper interaction
described in that PR, and the vacuous exemption test in finding 5).
- **Mutation-checked, not just green:** the two `resumeEvaluation`
tests, the repaired custom-function test, the cut+paste bypass fix
itself, and a sample of the newly-added per-method tests (`removeRows`,
`changeNamedExpression`) were each verified by reverting the
corresponding source line and confirming the new test fails, then
restoring.
- Full suite against the paired branch: **511 suites / 6373 tests, 6370
passed, 3 pre-existing skips, 0 failures.**

### Types of changes
- [ ] Breaking change
- [x] New feature or improvement
- [x] Bug fix
- [ ] Additional language file, or a change to an existing language file
(translations)
- [ ] Change to the documentation

### Related issues:
1. HF-307 (internal tracker; no public GitHub issue for this one)

### Checklist:
- [x] I have reviewed the guidelines about Contributing to HyperFormula
and I confirm that my code follows the code style of this project.
- [ ] I have signed the Contributor License Agreement. *(please
confirm/attach on your end - I can't verify this from here)*
- [ ] My change is compliant with the OpenDocument standard. *(N/A - no
worksheet function behaviour changes in this PR)*
- [ ] My change is compatible with Microsoft Excel. *(N/A - same
reason)*
- [ ] My change is compatible with Google Sheets. *(N/A - same reason)*
- [ ] I described my changes in the CHANGELOG.md file. *(intentionally
not done - internal-only change, no user-visible behaviour yet; a
CHANGELOG entry lands with the PR that actually activates the gates for
customers)*
- [x] My changes require a documentation update. *(done - @throws tags
and the @see list on LicenseCapabilityMissingError, see finding 6)*
- [ ] My changes require a migration guide. *(no)*

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Wide API surface change: many mutation entry points can throw when
restricted entitlements land in PR 3; deliberate exclusions (ungated
`resumeEvaluation`, Clipboard-only paste) affect runtime behavior under
partial licenses.
> 
> **Overview**
> Adds **license entitlement guards (HF-307 PR 2)** on the public
HyperFormula API so restricted entitlements throw
`LicenseCapabilityMissingError` instead of mutating the engine.
> 
> A private `ensureCapability` runs as the **first statement** on gated
instance methods when `isLicenseGateActive` is true: **Crud**
(sheet/cell/row/column moves and structural edits), **UndoRedo**,
**Clipboard** (`copy`/`cut`/`paste`—paste is Clipboard-only even when a
cut relocates cells), **Batching** (`batch`, `suspendEvaluation` only;
**`resumeEvaluation` stays ungated** so entitlement changes cannot
strand a suspended instance), and **NamedExpressions**
(add/change/remove). Reads, `isItPossibleTo*` helpers, and teardown
(`clearClipboard`, undo/redo stack clears, `destroy`) stay ungated.
> 
> **Build-time:** `BuildEngineFactory.ensureNamedExpressionsCapability`
rejects non-empty initial `namedExpressions` without the feature on
`buildFromSheets`/`buildFromSheet`/`buildEmpty`, but not on
`rebuildWithConfig`. The new error is exported from `src/index.ts`, with
`@throws` docs on affected methods. Until PR 3 supplies restricted
entitlements, these checks are no-ops for today’s default configs.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
690ef69. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…n, and capability table (#1730)

> **Update 2026-09-22 — review round with @sequba.** The sections below
are the PR as written
> before that round, kept for the reasoning they record. Where a
sentence no longer matches the
> code it is corrected inline and marked, rather than rewritten away.

## Update 2026-09-18 — folded #1731, #1736, #1737, #1740, #1741 into
this PR

So that no PR in this stack shows code a later PR rewrites, this PR now
contains, squashed with
no loss of content: the entitlement reader (rev 6, was #1740), the
notice-window warning (was
#1736), the two add-on tokens (was #1737), the function-metadata
filtering (was #1731), and both
capability-token dialects (was #1741). Those five PRs are closed, folded
here. The stack is now
`#1728 → #1729 → this PR → #1743`.

Two things below are stale as a result and are corrected here rather
than edited in place, so the
history stays legible: the title above (fixed), and every PR-number
reference in the body below
still says `#1740`/`#1741`/etc. — read those as "now #1730". The
CHANGELOG entries in the diff are
already repointed to #1730.

Everything below this line is the PR as it was written across 2026-08-11
through 2026-08-26,
before the fold — the decisions and verification it documents did not
change.

---

Third of four. Stacked on PR 2 (#1729), so review the commits on top of
it.

**Paired tests: handsontable/hyperformula-tests#32 — merge that first.**

## Why this PR exists at all

**A genuine typed license key did not work before this.**
`checkLicenseKeyValidity` recognizes three fixed strings and the older
25-character format; a typed key matches none of them and falls through
to `INVALID`. I verified this by building an engine with a real,
unexpired `[SUB]` key before touching anything:

```
validityState = "invalid"
C1            = #LIC!   "License key is invalid."
```

So a paying customer pasting a valid key got `#LIC!` in every cell plus
a console warning. An entitlement adapter alone would not have helped —
gate A kills the formulas first.

## What lands here

**1. Vendor the typed-key reader** into
`src/license/handsontable-license-key-parser/`
(~~`src/license/vendor/`~~, **renamed 22.09** at the reviewer's request
so the directory names the repository it mirrors) as TypeScript
(`allowJs` is off, `strict` is on, so this is a port, not a copy). This
follows the key spec's own recommendation of a vendored copy plus a
drift check rather than a shared package: a private dependency would
break `npm install` for open-source users of this GPL package.

`PROVENANCE.md` records the upstream commit and a per-file sha256, so
drift is detectable by re-cloning and re-hashing, and lists the
deliberate divergences.

**2. Resolve typed keys into both gates.** `resolveLicense` reads the
key once and answers both gates from that single reading, so they cannot
disagree about what the string says. Anything that is not a typed key —
`gpl-v3`, an older-format key, an empty string, a typed key with a
broken checksum — falls through to `checkLicenseKeyValidity` with its
**verdict unchanged**. That function is extracted from rather than
rewritten, so both paths share one warning-per-page flag instead of each
getting their own. Two additive edits to the file it lives in, both
affecting the legacy path too and both deliberate: `formatDate` now
reads its date in UTC (it printed the expiry a day early west of UTC — a
latent bug on the legacy path as well), and an `@internal` reset for the
warn-once flag was added because it made the console path untestable.

**3. The real capability table**, plus reading both payload shapes.

## Please confirm two things

**The gate-A/gate-B invariant.** Only a VALID typed key resolves to a
restricted entitlement; missing, invalid and expired all resolve to
`unrestrictedEntitlement()`. Gate A already stops formula evaluation on
its own — letting a bad key restrict the entitlement *as well* would
make PR 2's `ensureCapability` throw from the CRUD API, turning today's
"formulas fail, the API still works" into a silent breaking change for
every user whose key lapsed. Mutation-tested.

**The capability table is a DRAFT.** Membership is transcribed from the
packaging design's own per-function evidence file rather than invented,
and the transcript is checked two ways: it reproduces that file's five
published counts exactly (370 rows; 17 / 51 / 127 / 355 cumulative plus
15 operators), and all 423 registered function ids resolve into the 370
canonical entries once the 53 declared aliases are canonicalised, with
zero uncovered. But the design is still under review — the free tier's
exact contents and the placement of several function families are not
settled. Landing it now was a deliberate call, not a claim that it is
final.

## Judgement calls worth a reviewer's eye

- **Both payload shapes are read**, detected per product entry by the
presence of `capabilities`: the shipped shape (`tier`/`addons`/`exp`)
and the newer specified shape
(`capabilities`/`usage_until`/`release_until`/`notice`/`flags`). They
disagree about nearly every field, the newer one is still under review,
and only the first can be minted today. The newer spec also contradicts
itself on whether its dates are `YYYY-MM-DD` strings or numeric
timestamps, so both are accepted.
- **Feature gating is REAL** (updated 12.08, per Kuba's answers on the
task). An earlier revision of this PR granted all five feature areas
from the `core` token, which made feature gating inert by construction.
The five features now live on their own `feat:*` tokens: a rev-5 key
grants exactly the `feat:*` tokens it carries; the shipped shape — whose
vocabulary predates feature tokens — is granted all five by the adapter,
so a shipped-shape key's API behaviour is unchanged; legacy keys resolve
unrestricted (Kuba's carve-out). The exits stay ungated
(`resumeEvaluation`, teardown), per the never-gate-the-exit rule.
- **An unrecognized token no longer silences the key** (updated 12.08).
Silence comes solely from the key's `flags`; the earlier coupling
(`silent || unrecognized > 0`) suppressed strictly more than D3 asks for
and was confirmed an implementation error by Kuba.
- **The two add-on tokens are recognized but empty.** `spreadsheet` has
no agreed content — the packaging design names a *package* "Spreadsheet"
and the pricing work names a "Spreadsheet Bundle" *add-on*, and whether
those are the same set is unsettled; guessing either way silently sells
an empty add-on or duplicates a whole tier. Pinned by a test so filling
it has to be deliberate.
- **`noticeDays` is 0 for the shipped shape**, which has no notice
field. Inventing a default in the parser would put a product decision in
the wrong place. The newer shape carries `notice` and it is used.
- **`PROVENANCE.md` names the upstream private repo and commit.** That
is what makes the documented drift check runnable. Ratified by Kuba on
the task (D7-A, 12.08): kept as-is.

## Update 13.08 — key spec rev 5 read against the code

Reading rev 5
([doc](https://app.clickup.com/9015210959/docs/8cnjcyf-31675/8cnjcyf-48155),
updated 12.08) and running its own example payload through this branch
turned up three things, all fixed in the last commit:

- **Feature tokens are now OPT-IN.** §2.2 lists HyperFormula's entire
token vocabulary as `functions_1..4`, `spreadsheet`, `import_export` —
there is no `feat:*` entry at all. So a key that names no feature token
cannot be saying "no features"; nothing in circulation can express one.
Minting the spec's own §2 payload and running it: `setCellContents`,
`addRows`, `copy`, `undo`, `addNamedExpression` and `batch` **all
threw**. The same hole hit Handsontable-only keys and keys with
`hyperformula: null`, which lost the API that `core` used to give them,
silently (gate-A VALID = no console warning). A key that *does* name a
`feat:*` token still gets exactly what it names, which is the gating
Kuba asked for.
- **`no-console-warns` is honoured.** rev 5 spells the flag three ways —
§2.3 and the §2 example say `no-console-warns`, §4.3/§5.2 say
`silent-console`, earlier revisions said `silent`. Only the last two
were recognised, so a doc-conformant SaaS key printed console warnings
it had explicitly asked to suppress.
- **An unreadable `capabilities` rejects the key.**
Present-but-not-an-array fell through to the shipped-shape branch, which
was a free pass twice: every feature granted, and the rev-5 dates never
read — a subscription expired in 2020 resolved as perpetual.

**Still divergent from rev 5, deliberately:** §4.1 says a non-trial key
never hard-blocks, even past grace ("Trial: block. Non-trial: error only
(18.1)"). This branch keeps EXPIRED → `#LIC!`, per Kuba's D5=A (hard
blocking stays this release; the notice/soft-stop/hard-stop model is a
follow-up).

## Verification

Full unit suite green — **512 suites, 6344 tests** (after the 13.08
update). Each of the three fixes above is mutation-verified: removing
the opt-in rule fails 9 tests, removing `no-console-warns` fails 1,
removing the `capabilities` guard fails 2, and cutting the console
notification fails 1 (it used to fail none). Beyond that, the parts that
matter were mutation-tested rather than trusted: a wrong SHA-512 round
constant, a one-bit UTF-8 error, a broken invariant and a moved package
boundary each fail the tests that are supposed to catch them.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **High Risk**
> Changes commercial licensing, formula evaluation, and API gating
paths; incorrect gate-A/gate-B or capability resolution could block
paying customers or expose paid features.
> 
> **Overview**
> Adds **proprietary license entitlements**: vendored
**entitlement-key** parsing (rev 6) and **`resolveLicense`** wire
`Config` to gate A (key validity → `#LIC!` on function calls) and gate B
(capability tokens → restricted functions/API). Legacy/`gpl-v3` paths
stay on **`checkLicenseKeyValidity`**; only **valid** entitlement keys
get a restricted entitlement—missing/invalid/expired keys still resolve
**unrestricted** so the CRUD API does not start throwing while formulas
fail.
> 
> Replaces the placeholder **`core`** grant with a static **`fun:*` /
`feat:*`** capability table (group tokens, per-function tokens,
**`spreadsheet`** and **`import_export`** add-ons), case-insensitive
token normalization, and shared **`licenseAllowsFunction`** for the
interpreter and **`getAvailableFunctions()` / `getFunctionDetails()`**
(lists shrink only for valid partial keys, not for bad keys).
> 
> **Console licensing UX**: split expired messaging for usage vs release
axes, UTC date formatting, one-time approaching-expiry notice per key,
optional silence via config rebuild and key flags; docs/changelog
describe feature packages and `#LIC!` semantics.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
15d7231. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->




## ⛔ Update 18.08 — upstream retired this key format the same day, and
one claim above is wrong

A scoped spec-to-ship review of this PR (5 dimensions, every finding
adversarially verified) confirmed 5 findings. Four are test-hardening
and are fixed in the paired PR — see
[hyperformula-tests#32](handsontable/hyperformula-tests#32
"Update 18.08". The fifth needs a decision, not a patch, so nothing here
has been changed for it yet.

**The vendored reader cannot read the format upstream now ships.**
`handsontable/license-key` commit `01eae6530d4f` (2026-08-18 08:44 UTC,
DEV-2512, "Replace typed license keys with the entitlement key format
(rev 6)"), released as **4.0.0**, **deletes `src/typed-key/` outright**
— the exact five files `PROVENANCE.md` pins and hashes. Its own docs are
explicit: "The typed key format was **removed**, not deprecated… this
package cannot read a typed key." The replacement differs in three ways
this reader gates on:

| | this branch | entitlement format (rev 6 / 4.0.0) |
|---|---|---|
| format marker | requires a leading
`[SUB]_`/`[FREE]_`/`[TRIAL]_`/`[PERP]_` tag (`extractKeyData.ts:147`) |
**no tag** — the key *ends* with a bracketed block, which is the only
marker |
| payload version | requires `v: 1` (`extractKeyData.ts:199`) | **no `v`
field at all** — payload is exactly `{"products":{…}}` |
| checksum domain | SHA-512 over the whole key body, prose included |
over the **encoded payload only** — an explicit reversal, so the prose
becomes editable |
| unknown product | invalidates the key (`extractKeyData.ts:105`) |
tolerated — "kept, and ignored" |

Measured, not inferred: an authentic 4.0.0-format key built per
upstream's own `build-payload.js` gives `extractTypedKeyData(key)` →
`null`, `resolveLicense(key).validityState` → `invalid`, and `C1` →
`#LIC!` "License key is invalid.". Passing only the `[…]` block behaves
the same. The retired tagged format still resolves correctly on the same
build — so this is a format gap, not a robustness one.

Independently, Budzio published the byte-level spec for that format this
morning as a new page under key-spec rev 6 ("Technical implementation",
sections `T1`–`T8`, created 09:45 UTC). It matches upstream and confirms
all four rows above. Three of its rules this branch **already
satisfies**: checksum verified before any decode or parse ("order is
normative"), uppercase hex rejected rather than normalised, and
`__proto__`-safe result construction.

**What is genuinely at stake, and what is not.** The invariant holds
throughout: a rev-6 key resolves INVALID and therefore to
`unrestrictedEntitlement()`, so the CRUD API keeps working and only
formulas fail (verified). And myHOT PR #73 (merged to `master` 12:37 UTC
today, pinning `license-key#4.0.0`) states **"the typed keys never
reached production"**, with the legacy 25-character keys still generated
in parallel and validating exactly as before — that path is untouched
here. So the correction owed to this PR's own opening claim: no customer
ever held a typed key, so "a paying customer pasting a valid key got
`#LIC!`" describes a format that was never issued. The live exposure is
narrower but real — per that PR's surfacing table, **trial orders and
the HubSpot trial webhook now surface the entitlement key**, while
commercial orders and transactional emails still show the legacy key
only.

**Why this is not fixed in this PR.** Re-porting is the obvious move and
upstream made it cheaper than the original port (`sha512.js` and
`utils.js` are unchanged; the new `extractEntitlementKeyData` is
deliberately schema-free and built to be vendored, so only the
extractor, schema and constants need re-porting). But it is not a patch
to slip in: adopting the new shape rules verbatim also makes
`capabilities` mandatory and **invalidates every payload shape this
branch currently reads**, rev 6 is still status "for review", and the
work is arguably [HF-329](https://app.clickup.com/t/86cb119tz)'s
(unassigned, due 21.08, untouched since 04.08 — it now has concrete
content). Raised on the task as **D9**; the merge freeze until the spec
owner returns means escalating is the action, not a delay.

Also worth a reviewer's eye, since it sharpens the still-unanswered
**D8**: rev 6's own examples use `capabilities: ["functions_1",
"spreadsheet"]` / `["core"]` — i.e. **exactly the token names this
branch implements** — while the packaging design published 12.08 states
its vocabulary is `fun:<family>.<A|B|C>` and that "nothing else about
packaging exists at the technical layer". The two documents disagree,
and this branch conforms to the key spec rather than to the packaging
doc. A key minted in the packaging vocabulary resolves VALID with the
whole gated API and **zero functions**, silently (gate A says VALID, so
nothing prints, and `unrecognizedCapabilities` is exposed nowhere). That
outcome is now pinned by a test in the paired PR — pinned, not endorsed,
so whichever way D8 lands has to change it.

---

### Changed in the 2026-09-22 review round

- The guide no longer describes the key formats, and the validation
section points at the terms of the contract instead of enumerating the
two date axes. The changelog entry for the parser change was reworded
the same way, since it carried the same description.
- `PROVENANCE.md`: two paragraphs about the superseded `src/typed-key/`
port removed; the divergence list corrected. Its item 3 described
*upstream's* date check as if it were this port's, which has not been
true since a type guard was added, and that guard — the only divergence
that changes which keys are accepted — was missing from the list
entirely.
- The expired-key console message no longer blames the installed version
when the key ran out on the usage axis. That wording predates this axis
and was correct for every key `develop` can see; it is not for a
subscription key.
- The `implies` field and its recursive expansion are gone (see #1728),
so no capability token refers to another.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Kuba Sekowski <jakub.sekowski@handsontable.com>
…cate its static form (#1743)

> **Update 2026-09-22 — review round with @sequba.** The sections below
are the PR as written
> before that round, kept for the reasoning they record. Where a
sentence no longer matches the
> code it is corrected inline and marked, rather than rewritten away.

## Update 2026-09-18 — rebased onto the collapsed engine/tests PRs

`hyperformula#1730` and `hyperformula-tests#32` were folded from five
PRs each into one, so this
PR's base moved to `#1730`. Nothing in this PR's own diff changed; the
"9/9" numbering and the
`#1741` base reference below are stale — the stack is now `#1728 → #1729
→ #1730 → this PR`.

---

9/9 of the HF-307 stack, stacked on #1741. Pairs with
`hyperformula-tests#43` — **merge the tests PR first**. Finishes
decision **D2** on the one API it missed.

## The problem

`getRegisteredFunctionNames()` returned the whole catalogue under a
restricted key (measured: 422 names on a calculated-fields key,
including functions that evaluate to `#LIC!`). A function picker built
on it has exactly the problem #1724 (HF-349) and #1731's metadata filter
exist to prevent. Flagged in #1731's "found, not fixed here" section;
this closes it.

## What changed

**Instance method** — now lists exactly what the instance can evaluate,
sharing the one `licenseListsFunction` rule with the interpreter and the
metadata API so the three surfaces cannot drift:

- reads `getListableFunctionIds()` instead of
`getRegisteredFunctionIds()` — the protected built-ins are included
uniformly (`OFFSET` was missing before; `getAvailableFunctions` already
listed it);
- reads `config.translationPackage` — the instance's own snapshot —
instead of a fresh global `getLanguage` lookup, which can report a
localized name the instance refuses to evaluate, and which throws once
the host unregisters that language code;
- filters by the license, with the invariant intact: a missing, invalid,
or expired key does **not** shorten the list.

**Static method — deprecated, not removed.** See below; this is a change
from the first version of this PR.

## The static: deprecated rather than removed (changed after review)

The first version of this PR deleted
`HyperFormula.getRegisteredFunctionNames(code)`, citing HF-349 as
precedent. That was wrong, and the review caught it:

```
$ git show 3.4.0:src/HyperFormula.ts | grep -c "public static getRegisteredFunctionNames"   # 1
```

The method **is in the released 3.4.0 tag**, and HF-349's own commit
message says its removal was free precisely because *"Both methods are
unreleased, which is the only free moment to remove them."* So the
precedent does not extend here: deleting it would be a breaking change
in a minor release, against the Semantic Versioning this project states
it follows, and DEV_DOCS's Definition of Done would require a
migration-guide section that `docs/guide/` has no 3.x home for.

So it is now `@deprecated` with the wording this repo already uses for
that situation (`arraySizeMethod` / `arrayFunction` in 3.1.0:
"deprecated and will be removed in one of the next major releases"),
plus a `Deprecated` changelog entry. **Overturnable in one comment** if
you would rather take the break now — the direction is Kuba's D2 either
way; only the timing changed.

The deprecation notice is explicit that the two are not interchangeable:
the static translates into any registered language without an engine, so
migrating means building one (`HyperFormula.buildEmpty({ language:
'plPL' }).getRegisteredFunctionNames()`).

## D2's precondition, discharged

Kuba's D2 answer made the removal conditional: *"perhaps we can remove
the static methods, I'll check which methods does formula-builder use"*.
That check is done — searched the org, and **both consumers call the
instance form, not the static one**:

| Consumer | Call site | Form |
|---|---|---|
| `formula-builder` | `packages/core/src/engine/functionCatalog.ts:81` —
`this.engine?.getRegisteredFunctionNames?.()`, in try/catch, declared
optional in `engine/types.ts:66` | instance |
| `aurasheet` | `src/core/FormulaEngine.ts:97` —
`this.hf.getRegisteredFunctionNames()` | instance |

Both use it to build a **function picker** (`functionCatalog.ts`;
`FormulaAutocompletePlugin.ts`) — the surface #1724's rationale was
about — so the license filter added here improves both rather than
disturbing them. Under `gpl-v3` or any unrestricted key their lists are
unchanged.

## Spec-to-ship review (2026-08-20): also fixed here

- **The translation-snapshot change was unpinned.** Mutation-verified:
reverting it to the global lookup left 309 tests green, even though the
commit message and the new JSDoc both name it. Now pinned by a test that
unregisters the language after the engine is built — the scenario that
distinguishes the two implementations, since `getLanguage` throws for an
unregistered code while the snapshot keeps working.
- **The licence guide listed only two of the three narrowing methods.**
It now names this one too, in both places.
- **The new JSDoc over-claimed parity** with `getAvailableFunctions`:
for a function whose translation is the empty string this method returns
`''` while that one falls back to the canonical id. The claim is now
scoped to the ids and the licence rule, with the naming difference
stated.
- Reverting the static removal also removed the docs-build change it
required, so `docs/.vuepress/config.js` is untouched by this PR again
(it no longer builds one engine per documentation page).

## Testing

New suite pins the alignment in both directions (agrees with
`getAvailableFunctions` name for name; narrows on a restricted key;
never narrows on a bad key; aliases gate with their canonical; answers
from the instance's own snapshot). Full private suite: **517 suites /
6462 passing, 3 pre-existing skips**; `tsc --noEmit` and ESLint clean.

## Note for review

`functions-metadata.spec.ts` now skips listed ids with no plugin:
`OFFSET` is listed (it is callable) but parse-time resolved, so it
legitimately has no registry metadata.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019pxNP45obT2LZfjitaCv9o

## The codecov/project dip, traced

`codecov/project` was red at −0.02% while `codecov/patch` reported 100%
of the diff hit. Rather than
write that off as a threshold artifact, I measured coverage on this
commit and on its base and
diffed the per-file numbers:

| file | base (8/9) | this PR, before the fix |
|---|---|---|
| `src/HyperFormula.ts` | 674 / 675 | 675 / 676 |
| `src/interpreter/FunctionRegistry.ts` | 130 / 130 | **129 / 130** |
| total | 12 933 / 13 268 | 12 933 / 13 269 |

So the covered count did not move and one previously covered line
stopped executing — a real
consequence of this change, not a rounding artifact. The line was the
**instance**
`FunctionRegistry.prototype.getRegisteredFunctionIds()`, whose only
caller in the whole repository
was the method this PR rewrites. Nothing in `src/`, nothing in the
private suite, and nothing
outside (the class is not exported from `src/index.ts`) calls it any
more.

Removed, since this change is what orphaned it. The static
`FunctionRegistry.getRegisteredFunctionIds()`
is untouched and still used — by the deprecated static method above and
by three specs.

For the record, the single uncovered line left in `HyperFormula.ts` is
pre-existing and not mine:
`removeNamedExpression`'s unreachable `return []`, which already carries
a `codecov note` comment
explaining why it cannot be hit.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Documentation and deprecation only; no runtime behavior changes in
this diff.
> 
> **Overview**
> **Deprecates** the static
`HyperFormula.getRegisteredFunctionNames(code)` API (removal planned in
a future major) and documents how it differs from the instance method.
> 
> The static form is documented as reflecting the **global** function
registry and any registered language without an engine; callers should
migrate via `HyperFormula.buildEmpty({ language: '…'
}).getRegisteredFunctionNames()`. The **instance**
`getRegisteredFunctionNames()` JSDoc now states it lists everything in
the instance registry (registration, not license entitlement) and points
license-aware UIs to `getAvailableFunctions()`.
> 
> `CHANGELOG.md` adds a **Deprecated** entry for the static method.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
a8ffef3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---

### Open in the 2026-09-22 review round

- @sequba: "`getRegisteredFunctionNames` should still list ALL
functions". Agreed on the principle — this method answers for the
REGISTRY, and registration is not entitlement, while
`getAvailableFunctions()` and `getFunctionDetails()` answer about
availability and stay filtered. That removes most of this PR's
substance, leaving the deprecation note and a docs correction.
**Awaiting a decision on whether to reduce this PR to that or close it
with the deprecation folded into #1730.**
- Fixed here meanwhile: `docs/guide/release-notes.md` now carries the
same retroactive `### Removed` section for 3.4.0 that `CHANGELOG.md`
gained in this PR's history. The two are mirrors and only one had it.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Kuba Sekowski <jakub.sekowski@handsontable.com>
…flag

Four things from the 24.09 review.

The engine no longer knows any add-on. `spreadsheet` is out of the capability
table entirely, and import/export became `feat:import_export`, one more token
in the same opt-in set as the other feature areas. Which capabilities an add-on
bundles is decided where keys are minted; a key may still carry the word, and
this library now reads it the way it reads any word it does not know.

The static getRegisteredFunctionNames is no longer marked deprecated. That
decision was never taken, so the tag had no business being here. The method
documentation stays, since it explains a real difference between the two forms.

The changelog collapses to one entry under Added, written for someone using
the library rather than reviewing it.

isLicenseGateActive is removed. It short-circuited gate B for an unrestricted
entitlement, which the two-axis capability shape already does in one comparison
at each of the four call sites. Measured on 12000 function calls per
recalculation, nine interleaved A/B pairs: median -1.3% with the flag removed,
5 ms apart on the minima of a 180 ms run. It was not paying for the fourth way
to express the same condition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial line-by-line pass found it, and a mutation survived on it:
`feat:import_export` is recognized, so naming it switches the opt-in fallback
off like any other feature token. A key whose only feature token is this one —
a token that gates nothing today — loses CRUD, undo, clipboard, named
expressions and batching. Reproduced on a minted key: the same key spelling the
old `import_export` word keeps all six areas, spelling `feat:import_export`
keeps one.

That is the ratified opt-in rule working, not a defect, but it puts a contract
on the issuing side that nothing stated: once a key names this token it must
name every other area the customer bought. It is now on the token's own
documentation and pinned by a test that mints such a key and asserts the four
guards throw. Before this the exclusion of that token from the fallback trigger
could be deleted with the whole suite staying green.

Also from the same pass: the capability table's vocabulary list still advertised
two add-on tokens it no longer has; three comments still said "five" feature
areas where there are now six; the static getRegisteredFunctionNames still
deprecated by prose after its tag was removed, and now describes the difference
between the two forms without telling anyone to migrate; and the changelog entry
names the one existing method whose output this narrows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What @sequba asked for on 28.09. `isIsoDate` no longer type-checks the date; it
stringifies exactly as upstream does, so this file is a verbatim port again and
a drift review can compare it byte for byte instead of reasoning about a patch.

The check moved into the code this repository owns:
`hyperformulaDateFieldIsWellTyped` in licenseResolution.ts, applied where a
malformed payload already takes the invalid-key path. Measured, the verdicts a
customer can observe are unchanged — a string date resolves valid, an array date
resolves invalid either way; a number or an object never reached the type check
because the reader rejects their shape anyway.

One behaviour DID change and it is deliberate: only HyperFormula's own entry is
type-checked now. A malformed date in another product's entry used to invalidate
the whole key and no longer does. Another product's fields are not ours to
validate, and failing closed there would take down a customer whose HyperFormula
grant is intact.

Also restores the exports the capabilities refactor made module-private.
FUNCTION_GROUPS and UNGROUPED_FUNCTIONS are what the paired suite's drift checks
read to compare this transcription against the packaging document group by group;
they had stopped running silently, throwing on `undefined` rather than failing an
assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review of this branch reproduced both claims and neither holds.

RETRACTION: "which silently stopped running when these became module-private".
Measured on a cold compile, removing both exports gives TS2459 and the paired
suite reports ZERO tests — a hard red, not a silence. The original observation
behind that wording, `Cannot read properties of undefined`, was a stale ts-jest
cache artefact. The same word went into the review comment that asked for these
exports, so the premise there was overstated too; the ruling it produced stands
either way, and the comment now says what a reproduction actually shows.

The JSDoc fifteen lines above the export still opened with "Module-private: the
groups reach the outside only as entries of FUNCTION_CAPABILITY_TABLE, which is
where a test has to read them from" — the exact sentence the export overturned,
shipped in a public file and contradicting the line below it.

UNGROUPED_FUNCTIONS goes back to module-private. The ruling was conditional —
"if they are needed for tests, you can export them" — and nothing imports this
one; only FUNCTION_GROUPS is read by the drift checks. Widening a module's
surface for no caller is the kind of thing that later reads as intent.

tsc clean; full suite 517/517, 6551 passed, 3 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ediately found drift

What @sequba asked for on the PR, and what we agreed at the 18.09 review call:
a machine-readable pin beside the prose, and a CI guard that fails when upstream
moves. "Oprócz tego zrobiłbym tu jakiś taki plik automatyczny... w formacie json,
żeby potem CI to mogło łatwo czytać."

upstream.lock.json is the pin: repository, tracked branch, commit, and a sha256
per upstream file. PROVENANCE.md keeps the prose and loses the hash table, which
is what the call asked for - a markdown that says what this is, and a separate
file for the machine.

The first version of the script fetched each file AT THE PINNED COMMIT. That is
close to tautological: a commit is immutable, so it would have passed forever.
It now fetches from the tracked branch - `master`, the released code, since
`develop` there is work in progress - and compares against the pin. First run:

  DRIFT utils.js: master is 8fba0566…, the pin says 135a8396…

Upstream `6862da557` (2026-09-28, DEV-3031) changed `parseIsoDate` to test
`typeof isoDate === 'string'` before the pattern. That is the divergence this
directory used to carry and that we removed on 28.09 when moving to the verbatim
port; upstream adopted the same fix the same day. PROVENANCE.md said "upstream
has not adopted it (checked at c50ef40a, develop and master; no pull request or
issue proposes it)" - true when written, false within forty-eight hours, and
nothing would have told us. That is the whole case for the guard.

Not re-ported here, because it is a behaviour decision rather than a mechanical
one: upstream's check invalidates the whole key when ANY product's date is
mistyped, while hyperformulaDateFieldIsWellTyped deliberately only does so for
HyperFormula's own entry. Raised on the PR.

Without credentials the check FAILS rather than skipping. "Could not verify" is
not "verified", and a guard that reports success when it could not look is worse
than no guard. Verified by breaking it five ways: a wrong hash, no credentials,
an unpinned file in the directory, a pinned file missing, and the live drift above.

tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o upstream

@sequba asked three times for this and I had delivered half of it. 22.09: "Can we
add exceptions to tsconfig so these files could be the byte-identical copies of
the upstream?" 23.09: "The goal is (b)" — literally no local edits. 28.09: "let's
implement option 1." What landed on 29.09 was only the guard move; the files
stayed TypeScript ports. This morning's question — "so, now all the files inside
handsontable-license-key-parser/ are byte-identical copies of upstream?" — had the
answer "no", and the drift check I wrote first was a workaround for that.

Now the answer is yes, and `npm run check:vendored-parser` says so by measurement:

  vendored parser: handsontable/license-key/src/entitlement-key @ master, 5 files
  ok  constants.js  detect-format.js  extract-key-data.js  utils.js  sha512.js
  OK - every file is byte-identical to master

The five `.ts` ports are replaced by upstream's `.js`, copied without a character
changed, at `03810fd3c` on `master`. A hand-written `.d.ts` sits beside each one.
`allowJs` is on, which is what he proposed on 22.09.

The check now does what he asked for literally: it hashes OUR files, fetches the
same files from the tracked branch, and compares. `upstream.lock.json` is gone.
It stored a hash table, and he said we do not need to store hashes — true the
moment the files are copies, because then there is nothing to store a hash of
that is not already the file. `upstream.json` keeps only where the copy came
from. PROVENANCE.md loses the section explaining why byte-identity was impossible
and drops from 118 lines to 71.

One behaviour change, deliberate, because verbatim does not get to pick.

Upstream adopted the date type guard on 2026-09-28 (`6862da557`), the day before
we removed ours on his instruction. Taking the file whole brings it back, which
makes `hyperformulaDateFieldIsWellTyped` dead code — measured, not assumed: the
same four tests fail with it and without it. Removed.

Ours checked HyperFormula's own product entry only, on the grounds that another
product's fields were not ours to reject. Upstream's lives in the parser and
applies to every entry, so a mistyped date in a DataGrid entry now invalidates
the whole key, our grant included. That is upstream's rule and the key spec's
(T7). Four tests are rewritten to it, with the reversal named in a comment so the
next reader meets it in the suite rather than in a support ticket.

Two mechanical consequences of shipping JavaScript from `src/`:

- Jest transformed only `.ts`, so the ES modules went to Node raw and every
  licence suite died on the first `export`. The transform now covers `.js`.
- ESLint produced 216 errors, all of them upstream's semicolons. `--fix` would
  edit the files, which is the one thing they must not have, so the directory
  joins `3rdparty` in `.eslintignore`. The `.d.ts` beside them are ours and stay
  linted.

Verified: full suite 517/517 · 6551 passed · 3 skipped · 0 failed; `tsc --noEmit`
clean; `npm run lint` on `src` 0 errors; `npm run bundle-all` green — four UMD
bundles, commonjs and the publish-package check. The drift check was broken three
ways on purpose and caught each: a local edit, an unaccounted `.js`, a missing file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ckage's own parser

The 22.09 / 23.09 / 28.09 rulings asked for byte-identical copies of upstream with
no local edits. The previous commit delivered that against the wrong directory:
`src/entitlement-key/*.js` is license-key's internal parser. Its own CLAUDE.md
says what products copy - "They copy vendor/entitlement-key-reader/ - a general,
dependency-free TypeScript reader" - and that directory appeared upstream on
2026-09-28 13:22Z, two hours after the ruling, so neither side could have pointed
at it at the time.

This commit copies that directory whole, at tag 4.0.1, as its README prescribes.
Everything the .js detour needed goes with it: allowJs, five hand-written .d.ts,
the Jest transform for .js, the lint exemption for .js, the stored-hash pin. Two
independent reviews had measured that the .js route shipped typings generated
from upstream's JSDoc - EntitlementKeyData and LicenseKeyFormat gone from the
published contract, parseIsoDate declared as (string) while the change was about
accepting non-strings, and the Handsoncode licence header missing from five
shipped files. With TypeScript sources the published typings come from the code:
`extractEntitlementKeyData(licenseKey: string): EntitlementKeyData | null`,
`parseIsoDateToTimestamp(isoDate: unknown): number | null`.

One declared divergence, and only one - so "no local edits" is one line short.
HyperFormula compiles with TypeScript 4.0.8; upstream targets ^5.9.3, and 4.0.8
does not narrow `unknown` through `!== null && typeof === 'object'`. One cast on
one line of extractKeyData.ts makes the directory compile here (measured: the
same files type-check clean under 5.9.3, and the un-cast line starts compiling at
4.5 - 4.4.4 fails, 4.5.5 passes). The cast is recorded in upstream.json with its
reason and its expiry; the drift check substitutes it before comparing. Tags are
immutable, so the check cannot see upstream fix the line on a branch; it fails on
the next tag, and re-taking the copy there makes the entry stop matching, so the
shim comes out rather than riding along. The alternatives - wait for a
license-key release carrying the cast, or move HyperFormula to TypeScript >= 4.5 -
are put to the reviewer; HyperFormula has no push access to license-key, so the
upstream fix has to be requested, not made.

The drift check is now upstream's own `diff -r`, done through the API: every file
of the upstream directory at the pinned tag, byte for byte, plus a check that the
tag still points at the pinned commit, plus what a `diff -r` would not tell you -
a local file upstream does not have fails the check, because a `foo.ts` beside a
copied `foo.ts` wins module resolution silently (an earlier version printed
"byte-identical" while exactly that shadow flipped a key from INVALID to VALID).
A newer upstream tag fails it too. A 401/403/404 is reported as an access problem,
not as drift. Broken eight ways on purpose, caught eight times.

It also runs in CI now, which an earlier commit message claimed and no workflow
did: vendored-parser.yml, on pull requests that touch the directory and on pushes
to master and develop, plus a weekday cron - which GitHub runs on the default
branch only, so it watches releases, not open PRs. It needs LICENSE_KEY_REPO_TOKEN
as a repository secret and is red until that exists - deliberately, and visibly
on this PR from the first push.

Engine-side consumers change by name only: detectFormat, extractKeyData,
encoding (parseIsoDateToTimestamp for parseIsoDate), types (ProductEntitlement
for EntitlementProductGrant), CHECKSUM_LENGTH. detectLicenseKeyFormat now takes
the literal keys from the caller and answers 'literal'; HyperFormula never passed
any and only asks "is this an entitlement key", so its routing is unchanged.

The reader's own date-type check made hyperformulaDateFieldIsWellTyped dead code,
so it is removed - which reverses the 28.09 instruction to move the guard into
licenseTermsOf and the HF-entry-only narrowing described on 29.09: a mistyped date
in another product's entry now invalidates the whole key. A verbatim copy does not
get to pick; that is upstream's rule and the key spec's (T7), and it is pinned.

The reader also carries window evaluation and grant picking
(readEntitlementLicense, classifyEntitlement, getLicenseGrants). HyperFormula
keeps evaluating windows in licenseResolution.ts and does not use those yet;
consolidating onto them is a separate decision, not taken here.

tsc clean; full suite 517/517, 6549 passed, 3 skipped, 0 failed; lint on src 0
errors; bundle-all green with the reader present in every bundle; drift check OK.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ommit

A review of the previous commit found two things it said that its code did not do.

"The check fails the moment upstream touches that line" - it cannot. It compares
against an immutable tag, so an upstream fix on a branch never reaches it. What it
sees is the next tag, and re-taking the copy there is what makes the shim entry
stop matching. Reworded in the script header, the pin's comment and PROVENANCE.

`pin.commit` was decorative - nothing read it, so a moved tag or an edited pin
passed silently, the opposite of the commit tracking agreed on 18.09. The check
now asserts that the pinned tag resolves to the pinned commit, and fails if the
tag is gone or points elsewhere. Verified by editing the commit in the pin.

The TypeScript floor in the divergence's expiry was wrong: measured, the un-cast
line fails under 4.4.4 and compiles under 4.5.5, so it reads ">= 4.5", not 4.9.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…res its permissions

Two things CI said about 71a0af6.

codecov/patch fell to 78%: 149 uncovered lines, all inside the vendored reader,
mostly in the modules HyperFormula does not call yet (classify, readLicense,
grants, buildDate, index). That directory is a byte-identical copy of upstream's
vendor/entitlement-key-reader - upstream tests it, and this repository may not
edit it - so its coverage measures nothing about HyperFormula. codecov now
ignores it for the patch and project statuses. The local jest report is left
alone on purpose, so the numbers are still there for anyone who wants them.

CodeQL: "Workflow does not contain permissions". Every other workflow here sets
`permissions: contents: read`; vendored-parser.yml now does too. The job only
reads the repository and talks to a different one with its own token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… access error

Review found the realistic failure - a real tag with a commit that does not match
it - blamed access: the pin pointed at 4.0.0, the reader directory does not exist
at 4.0.0, the listing 404'd, and the check stopped there with "the token has no
access to it, or the tag does not exist", both false. The tag-to-commit assertion
ran after the listing, so it never spoke. It now runs first, the 404 message names
the third cause (the directory absent at that ref), and the newer-tag rule is
skipped when the pin itself is not a release tag, which used to list every tag as
"newer" than `master`.

The same review found one sentence, in three places, promising more than the
code does: "a shim cannot outlive the tag it was written against". It can - a
newer tag that leaves the line alone re-takes cleanly with the same cast. What the
check does guarantee is that an upstream fix of the line cannot be missed, because
the swap stops matching at that re-take. The three places now say that, and
PROVENANCE's "Checking it" lists the tag-to-commit check it had left out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ck found first

The previous commit moved the tag-to-commit assertion ahead of the listing and
said the mismatch is now named. It was collected, not printed: the listing's
failure exited before the problem list was shown, so the output still blamed
access. Verified on the realistic case - pin at 4.0.0 with the 4.0.1 commit -
which now prints the mismatch before the 404.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
…ream prescribes

The vendored reader's root README and its own guide both say a product
calls `readEntitlementLicense` and keeps beside the copy only what is
product-specific. HyperFormula used the reader's parsing pieces and
re-implemented the lifecycle on top of them. That layer is gone:

- the reader verifies the key, picks the `hyperformula` entry, places it in
  its lifecycle window and reads its flags;
- `licenseResolution.ts` maps the ten lifecycle states onto gate A (the
  soft-stop states stay VALID and quiet, as decision D5-A builds only the
  notice and the hard stop), and keeps the capability-token meaning and
  the console messages.

Two observable changes, both towards what every other product reading the
same key does:

- a key granting other products but not HyperFormula is INVALID
  (`product_missing`) and restricts nothing; it used to be VALID with an
  empty grant;
- only `no-console-warns` silences the console; `silent-console` and
  `silent` are unknown flags now. The generator emits neither.

Measured: tsc 0, lint 0, drift check OK, 517/517 suites (6543 passed,
3 skipped), bundle-all OK. Three mutations of the state mapping (soft stop
as expired, notice never printed, release_expired as valid) each fail the
license suite (1, 8 and 5 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LicenseEntitlement: drop "until PR 3 lands" (it landed); `unrestricted`
  also covers missing, invalid and expired keys.
- licenseResolution: D5-A keeps hard blocking and defers the windows; the
  notice was built anyway. The old wording attributed the notice to D5-A.

Comments only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ruling on #1728 (2026-10-01), for a non-trial hard stop and
release_expired: "Don't block. It should just print an error in console."
That matches the reader's guide ("18.1 never blocks a paying customer")
and rev 5 §4.1.

- resolveLicense now returns `blocksEvaluation` beside `validityState`.
  The state stays EXPIRED, so VERSION() and the message still say so;
  gate A in the interpreter reads the new flag instead of the state.
- usage_hard_stop and release_expired: EXPIRED, not blocking, the
  message goes to console.error, and the key keeps its own grants (the
  reader reports it as licensed, so it never gains more on expiry).
- trial_hard_stop, invalid, missing and every classic 25-character key
  block exactly as before.
- docs/guide/license-key.md says which expired keys still give #LIC!.

Measured: tsc 0, lint 0, 517/517 suites (6550 passed, 3 skipped),
bundle-all OK. Paired tests: hyperformula-tests 107ea9d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… expiry

Bugbot (Low) on 85dc976: the public guide should not describe the
anatomy of proprietary key formats. The paragraph now says an expired
key either behaves like an invalid one or keeps working with what it
grants and prints a console error, depending on the license terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as Handsontable does

Compared with Handsontable's implementation of the same reader
(handsontable/src/helpers/mixed.ts), HyperFormula disagreed about one key
in two ways: it printed a day rebuilt from the grace period
(usage_until + 1 + grace, "February 1, 2020") where Handsontable prints
the key's own date ("2020-01-01 (UTC)"), and it said nothing during the
grace period where Handsontable prints an error.

- licenseKeyValidator: a per-state table (severity + text) transcribed
  from the specification, the same rows as Handsontable's, chosen by the
  reader's lifecycle state. Warnings while the key works, errors once it
  has run out, including the grace period. release_expired names the
  installed version and the downgrade clause. Once per key per page.
- Classic 25-character keys keep their messages and their single
  once-per-page flag byte for byte.
- Removed the rebuilt-date path (firstUncoveredDay), the separate notice
  function and the usage/release split of the classic expired message.
- PROVENANCE.md records the owner's ruling on the one declared cast
  ("Neither. Let's keep this solution for now.", 2026-10-01).

Measured: tsc 0, lint 0, drift check OK, 517/517 suites (6557 passed,
3 skipped), bundle-all OK. Paired tests: hyperformula-tests (this push).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
marcin-kordas-hoc and others added 4 commits October 2, 2026 04:56
Bugbot (Medium) on e929f23: keying the once-per-page set by the key
alone dropped a later, different message for the same key, so a page that
stayed open while a key moved from its notice window into expiry never
printed the expiry error. The set now holds key + message text, which is
what the specification asks ("each distinct message once per key per
page"). A soft stop and a hard stop share one text, so it does not repeat.

Measured: tsc 0, lint 0, full suite green. Paired test in hyperformula-tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove ticket, pull request, task, review-date and decision references,
internal specification section numbers and a personal name from comments
in the license code. The reasons they carried are kept in plain terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s are gated

The comment said function availability is not license-gated and pointed at a
future change. Function listing is gated by the key now, so the comment stated
the opposite of the behaviour and carried ticket ids. It now says why the page
is generated with a fully entitled key.

Comment only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
license-key 5.0.0 changes the key: the checksum now covers its sentences as well as its payload, a block
on its own is no longer a key, and "4.x keys are no longer valid; there is no compatibility path". The
reader copied here was 4.0.1, so a key issued by a 5.0.0 generator would read as invalid and block
evaluation, and a key from a 4.x generator is invalid for 5.0.0. The copy has to match the generator
that issues the keys.

- The 13 files of `vendor/entitlement-key-reader` are re-taken from the tag; five differ from 4.0.1
  (extractKeyData, readLicense, types and the two guides).
- The one declared divergence is unchanged: the same `Object.keys(current as object)` cast, on the same
  line of text (now line 146), for TypeScript 4.0.8. `upstream.json` and PROVENANCE.md carry the new
  tag, commit and date.
- A comment on the key identity no longer says a bare block is the same license.
- No engine logic changes.

`npm run check:license-key-parser-drift` reports the directory identical to the tag apart from the
declared cast. tsc and lint are clean, 512 suites (6413 tests) pass and the bundles build. Paired
tests: same branch name in hyperformula-tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
hyperformula-docs 5b72a24 Commit Preview URL

Branch Preview URL
Oct 06 2026, 07:22 AM

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Performance comparison of head (5b72a24) vs base (b0ee8e4)

                                     testName |    base |    head | change
--------------------------------------------------------------------------
                                      Sheet A |  490.54 |  505.23 | +2.99%
                                      Sheet B |  159.65 |  163.37 | +2.33%
                                      Sheet T |  141.36 |  145.02 | +2.59%
                                Column ranges |  473.07 |  475.06 | +0.42%
                                Sorted lookup | 13932.8 | 14646.2 | +5.12%
Sheet A:  change value, add/remove row/column |   15.92 |   16.18 | +1.63%
 Sheet B: change value, add/remove row/column |  132.36 |  136.82 | +3.37%
                   Column ranges - add column |  153.66 |  145.95 | -5.02%
                Column ranges - without batch |  469.24 |  460.68 | -1.82%
                        Column ranges - batch |  117.96 |  116.25 | -1.45%

Base automatically changed from hf-307-entitlement-gating-pr1 to develop October 7, 2026 16:34
@codecov

codecov Bot commented Oct 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.29787% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.31%. Comparing base (5abbbd9) to head (5b72a24).
⚠️ Report is 1 commits behind head on develop.

Files with missing lines Patch % Lines
src/helpers/licenseKeyValidator.ts 91.30% 4 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #1794      +/-   ##
===========================================
- Coverage    97.32%   97.31%   -0.01%     
===========================================
  Files          195      201       +6     
  Lines        15739    15953     +214     
  Branches      3461     3533      +72     
===========================================
+ Hits         15318    15525     +207     
- Misses         413      420       +7     
  Partials         8        8              
Files with missing lines Coverage Δ
src/BuildEngineFactory.ts 100.00% <100.00%> (ø)
src/Config.ts 94.64% <100.00%> (+0.52%) ⬆️
src/HyperFormula.ts 99.76% <100.00%> (+0.01%) ⬆️
src/error-message.ts 100.00% <100.00%> (ø)
src/errors.ts 100.00% <100.00%> (ø)
src/index.ts 100.00% <100.00%> (ø)
src/interpreter/Interpreter.ts 95.48% <100.00%> (+0.11%) ⬆️
src/license/CapabilityRegistry.ts 100.00% <100.00%> (ø)
src/license/LicenseEntitlement.ts 100.00% <100.00%> (ø)
src/license/capabilities.ts 100.00% <100.00%> (ø)
... and 4 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants