Skip to content

HF-307: license-key entitlement gating — capability model, key reader, API guards - #1728

Merged
sequba merged 57 commits into
developfrom
hf-307-entitlement-gating-pr1
Oct 7, 2026
Merged

sequba merged 57 commits into
developfrom
hf-307-entitlement-gating-pr1

Conversation

@marcin-kordas-hoc

@marcin-kordas-hoc marcin-kordas-hoc commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator

What this is

HF-307, feature packages and add-ons. A proprietary license key can now grant a subset of the library instead of only answering yes/no. The key carries a flat list of capability tokens, and the engine grants the union of what those tokens name.

This started as a stack of four PRs and is now one. #1729, #1730 and #1743 were merged into this branch; #1731, #1736, #1737, #1740 and #1741 were closed once their content landed here. Paired tests: handsontable/hyperformula-tests#30.

Twenty-four files are new; 22 of those live under src/license/.

The earlier revisions of this description, written while the stack still existed, are in this description's edit history.

Two gates

  • Gate A — is the key valid? Classic keys are validated and messaged as before, except that the build release date and the expiry date in the console message are now read in UTC (see below); entitlement keys are validated too, with their own console messages. A key that is missing, invalid, or expired in a way that blocks evaluation (a classic key, or a trial past its grace period) blocks the library:
    • every function except VERSION and OFFSET evaluates to #LIC!, as before;
    • new: every gated public-API method throws LicenseCapabilityMissingError naming the key's state (License key is missing. Feature crud is not available.), and so does building an engine with named expressions. Before, such a key stopped only function calls: operator formulas, plain data and the whole API kept working. Code that builds an engine without a valid key now gets this error from its first gated call. The changelog lists it under Changed.
  • Gate B — what does the key grant? New. Functions and public-API areas, resolved on two axes (functions, features), each either a set or 'all'.

One invariant binds them: only a key that lets the build evaluate restricts anything through gate B: a valid key, or an expired subscription or perpetual key, which keeps its own grants. A missing, invalid or blocking expired key resolves to an entitlement that restricts nothing, and gate A alone stops it. Its errors therefore always name the key's state (License key is invalid.), never a missing grant (… is not included in your license), and getAvailableFunctions() still describes the whole catalog under it.

What a restricted key does

  • A function outside the grant evaluates to #LIC! rather than disappearing, in its own cell: an array function the license stops reserves no spill range, so it never shows #SPILL! instead.
  • A gated public-API method throws LicenseCapabilityMissingError. Its feature property names the area the call needed, as a value of FeatureId, which the package now exports.
  • The matching isItPossibleTo* predicates (isItPossibleToAddRows() and the rest) answer false for a call that would throw it, and so do isThereSomethingToUndo() and isThereSomethingToRedo() without undo_redo.
  • getAvailableFunctions() and getFunctionDetails() describe only what the instance can actually evaluate. getRegisteredFunctionNames() still lists every registered function.
  • Valid classic 25-character keys, gpl-v3, internal-use-in-handsontable and hftrial-0168e-1f2b7-47158-70b05-0842f are unaffected: they resolve to an unrestricted entitlement and never consult the capability table. An invalid or expired classic key gets gate A's behavior above.

Capability tokens

Function grants use the packaging vocabulary: fun:all, fun:<family>.<A|B|C> and per-function fun:<NAME>. The callable operator forms (HF.ADD and friends) sit under fun:operator.A; infix operators work under any key. Token names are matched case-insensitively.

Gated API areas use feat:* tokens, one area each. A key is granted exactly the areas it names, and feat:all is how it names all of them; a key naming none is granted none. The key generator (license-key 5.1.1) cannot write fun:* or feat:* tokens yet, so a key it issues today grants no gated function and no gated API area.

An earlier revision read the absence of a feat:* token as "this key does not talk about features" and granted all five areas; that rule was removed deliberately.

The engine knows no packages and no add-ons. A key may carry a word like spreadsheet or functions_1; it is read as any other word this version does not know: it grants nothing, nothing reports it, and it never subtracts anything.

The key reader

src/license/handsontable-license-key-parser/ is a copy of the reader upstream publishes for products (license-key's vendor/entitlement-key-reader/, tag 5.1.1). Twelve of its thirteen files are byte-identical; the thirteenth differs by one cast, explained below. The engine calls its readEntitlementLicense, as upstream's README prescribes: the reader verifies the key, picks the hyperformula entry, places it in its lifecycle window and reads its flags. licenseResolution.ts keeps what the guide leaves to the product: the meaning of the capability tokens, the console messages, and which lifecycle states block evaluation. A non-trial key past its grace period, or past the build its maintenance covers, reports EXPIRED and prints a console error but keeps evaluating with its own grants. A trial past its grace still gives #LIC!. Classic 25-character keys are read as before, with one fix: the build release date they are compared with is parsed in UTC, and the expiry date in their console message is printed in UTC. Previously, east of UTC, a classic key that expired the day before the build was released was still accepted, and west of UTC, the console printed an expiry date one day too early. The changelog has a Fixed entry for it.

Since 5.x, keys are in format version 2: the payload carries a digest of the prose, so a key whose prose was edited or removed (the bare [...] block), or that has text after its block, is invalid (M7). Whitespace and line breaks saved as text (\n) are ignored anywhere in the key, inside the block too. Following that, the license key guide tells users to pass the whole key as issued. The spec's state table has the matching rows (S31 changed, S39 added).

Console messages for entitlement keys are the specification's text, per lifecycle state, the same table Handsontable prints (handsontable/src/helpers/mixed.ts): the date exactly as the key carries it, a warning while the key works and an error once it has run out (the grace period included). They print every time a key is resolved: unlike classic keys, which keep their once-per-page flag, entitlement keys keep no record of what they already printed, so one key can print the same message more than once on a page. The cast in extractKeyData.ts stays.

Following the reader changed three things:

  • A key that grants other products but not HyperFormula is INVALID and restricts nothing. An earlier revision had it VALID with nothing granted.
  • A malformed date in another product's entry invalidates the whole key again, as upstream does.
  • Only no-console-warns silences the console. silent-console and silent are unknown flags; the generator emits neither.

upstream.json pins the tag. npm run check:license-key-parser-drift compares every file git tracks in the directory against the pinned commit and runs in CI (vendored-parser.yml; currently red: the LICENSE_KEY_REPO_TOKEN secret exists but cannot read the upstream repository). One declared divergence, a single cast for TypeScript 4.0, is recorded there with its expiry.

Also in this PR

  • Moving or pasting a formula with an undefined name to another sheet no longer adds that name as an empty global named expression. Before, the formula's #NAME? turned into an empty value, and the workbook serialized a named expression nobody added, which a key without named_expressions then could not build again. The bug predates this PR; the license gate turned it into a hard failure. Only a name the source sheet defines locally is copied to the global scope now (Operations.updateNamedExpressionsForTargetAddress).
  • One rule for stopped function calls. FunctionCallLicenseGate decides which calls the license stops and with which #LIC! error. The interpreter and the array size predictor both use it, which is what keeps a stopped array function to one cell.
  • API reference examples. Every @example block in HyperFormula and its events, and the API reference landing page, passes licenseKey: 'gpl-v3'. Four examples that threw for their own reasons (moveColumns, getNamedExpressionValue, changeNamedExpression, getAllNamedExpressionsSerialized) are fixed.

VERSION()

VERSION() returns only the version (HyperFormula v3.4.0), with no license status, under every key. The function's catalog description and the changelog say so.

Docs

  • Every engine built in a docs/guide example passes licenseKey: 'gpl-v3': without a key, the gated methods those examples call now throw.
  • DEV_DOCS.md describes src/license/ and the vendored reader, and the new-function checklist has a step for functionCapabilities.ts: a built-in missing from it is not gated at all.
  • The changelog has a Changed entry for what a blocking key does to the gated API. It is not marked as breaking, for the reason under "Two gates".

Not built here, on purpose

  • The shared conformance fixtures both products would run in CI. Needs the other side to exist.

Review

Reviewed on 2026-09-22, 2026-09-23, 2026-09-24, 2026-09-28 and 2026-09-30, with the 2026-09-29 rulings in the thread. The feat:import_export token is gone. Merge order: this PR before hyperformula-tests#30.

Open:

  • The drift-check workflow is red: its secret cannot read the upstream repository.
  • The key generator cannot write fun:* or feat:* tokens yet, so no key issued today grants a gated function or API area.
  • Spec rows S32–S35 and S38 have no tests yet.
  • Two places where the engine and the spec disagree: a custom function evaluates to #LIC! under a blocking key, and getAvailableFunctions() lists the whole catalog under a blocking key.

🤖 Generated with Claude Code


Note

High Risk
Changes core licensing behavior for formula evaluation and most mutating public API paths; incorrect gating or key parsing could block paying customers or expose paid features.

Overview
Adds feature-package licensing: proprietary keys can grant subsets of functions (fun:* tokens) and API areas (feat:* tokens). Restricted functions evaluate to #LIC!; gated API methods throw LicenseCapabilityMissingError, with matching isItPossibleTo* / undo-redo predicates returning false. getAvailableFunctions() / getFunctionDetails() now list only what the instance’s key allows.

Gate A still blocks evaluation for missing/invalid keys and blocking expirations (classic or trial past grace): almost all functions become #LIC! (except VERSION / OFFSET), and gated API—including building with named expressions—throws instead of silently allowing edits. VERSION() no longer embeds license status.

Integrates a vendored handsontable/license-key entitlement reader (pinned tag, drift CI, one TS 4.0 cast shim), licenseResolution, and capability tables in functionCapabilities.ts / featureCapabilities.ts. Classic 25-char keys get a UTC fix for release vs expiry comparison and console dates.

Also fixes move/paste of formulas referencing undefined names on another sheet (no spurious global named expression), updates docs/examples with licenseKey: 'gpl-v3', and excludes the vendored tree from ESLint/Codecov.

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

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
@qunabu

qunabu commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 11, 2026 •

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 f9819f7 Commit Preview URL

Branch Preview URL
Oct 07 2026, 04:19 PM

@github-actions

github-actions Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

Performance comparison of head (f9819f7) vs base (5abbbd9)

                                     testName |    base |    head | change
--------------------------------------------------------------------------
                                      Sheet A |  481.43 |  486.29 | +1.01%
                                      Sheet B |  153.62 |  155.83 | +1.44%
                                      Sheet T |  134.16 |  136.73 | +1.92%
                                Column ranges |  512.32 |  515.23 | +0.57%
                                Sorted lookup | 15339.8 | 15359.3 | +0.13%
Sheet A:  change value, add/remove row/column |   15.16 |   16.36 | +7.92%
 Sheet B: change value, add/remove row/column |  135.57 |  142.56 | +5.16%
                   Column ranges - add column |  154.71 |  166.29 | +7.48%
                Column ranges - without batch |   483.8 |  499.87 | +3.32%
                        Column ranges - batch |  123.16 |  124.37 | +0.98%

@Tobiadefami Tobiadefami left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 0a966e3 together with the paired tests at 28952a33. The entitlement model, capability resolution, private Config wiring, and interpreter gate look good at this layer. The focused paired suite passes (4 suites / 23 tests), the current engine checks are green, and I found no material issue in this PR.

Comment thread src/license/CapabilityRegistry.ts Outdated
Comment thread src/license/capabilities.ts
Comment thread src/license/capabilities.ts Outdated
…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>
marcin-kordas-hoc and others added 5 commits September 22, 2026 16:29
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>
Comment thread src/license/handsontable-license-key-parser/PROVENANCE.md Outdated
Comment thread src/license/capabilities.ts Outdated
Comment thread docs/guide/license-key.md Outdated
Comment thread docs/guide/license-key.md Outdated
Comment thread docs/guide/license-key.md Outdated
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
Comment thread src/helpers/licenseKeyValidator.ts Outdated
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
@cursor
cursor Bot force-pushed the hf-307-entitlement-gating-pr1 branch from ba3f8a6 to 42a3c7c Compare October 7, 2026 07:35
sequba and others added 6 commits October 7, 2026 12:47
…one place

The interpreter now reads the license decisions from its config once, in
its constructor, instead of through the config's getters on every
function call, and skips the alias and table lookups for a key that
grants every function. A config never changes after it is built, and
updateConfig builds a new interpreter, so the cached values cannot go
stale. test:performance shows no regression against develop.

Alias canonicalisation, used by both the interpreter and the function
metadata API, moves to FunctionRegistry.getCanonicalFunctionId.

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

- LicenseCapabilityMissingError carries the feature the call needed in
  its feature property, and FeatureId is exported (by name and as
  HyperFormula.FeatureId), so callers no longer parse the message. Its
  description now also covers a key that blocks every gated feature.
- Entitlement-key console messages print every time a key is resolved;
  the per-key de-duplication, and with it the use of the reader's
  internal sha512 and encoding modules, is gone. The reader's types are
  imported through its index.
- The unlicensed-key messages get their own notifier, and the message
  helpers take non-null parameters and have JSDoc.
- Remove FeatureId.CustomFunctions and
  LicenseEntitlement.unrecognizedCapabilities, which nothing used, and
  stop exporting FUNCTION_GROUPS.
- TypeDoc no longer documents the vendored reader.

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

- The license key guide says that a missing or invalid key blocks every
  gated API method with a LicenseCapabilityMissingError, lists the gated
  methods, and says what keeps working.
- VERSION's catalogue description and the changelog say that it returns
  only the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Requests time out after 30 s and the job after 10 minutes; a failure
  is reported as an access, network or configuration problem (a 301 is
  a moved or renamed repository), never as drift.
- Only files git tracks are compared, as git stores them, so untracked
  files and CRLF checkouts no longer fail the check.
- Tags are read 100 per page following the pagination, a leading "v"
  and prerelease suffixes are understood, and files are fetched at the
  pinned commit rather than the tag.

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

- Read tracked files with `:./path`, so they resolve from the repository
  root like the listing does, also when the checkout sits inside another
  repository.
- Failures are classified as a moved repository (301), no access
  (401/403/404), a GitHub API problem (any other status), a network
  problem (a socket error or timeout) or an unexpected response.
- Messages and the 404 hint name the pinned commit the files are read
  at, not only the tag.

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

- The isItPossibleTo* predicates answer false when the method they ask
  about would throw a LicenseCapabilityMissingError: under a key that
  blocks evaluation, or a key without the feature. isFeatureAllowed is
  the one rule behind them and ensureFeatureAllowed.
- Comments and the API reference distinguish a key that blocks
  evaluation (missing, invalid, an expired classic key, a trial past its
  grace period), which restricts nothing by grant, from an expired key
  that keeps evaluating with its own grants. The @throws lines of the
  gated methods also cover a key that blocks evaluation.
- The static getRegisteredFunctionNames no longer claims the instance
  form answers under the license.
- types-of-errors.md and the license key guide describe the same rules.
- The changelog gains a Fixed entry for the time-zone bug in classic-key
  validation that this PR fixes.
- Use US spelling.

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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0d00cec. Configure here.

Comment thread src/HyperFormula.ts Outdated
sequba and others added 10 commits October 7, 2026 15:14
…stive table

Replace the two state lists, with their implicit "blocks" default, by a
Record<LicenseState, LifecycleVerdict>. A state added upstream now fails
compilation until it is classified. Behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The JSDoc is published by TypeDoc; describe the expired perpetual key
without naming the payload field, as the license guide does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under a missing or invalid key, an expired classic key, or a trial past
its grace period, the gated methods throw, a build with named
expressions throws, and isItPossibleTo* returns false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a key, the gated methods the examples call now throw. Every
engine built in docs/guide gets licenseKey: 'gpl-v3', as the docs
content guide requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds src/license and the vendored reader to the layout and the core
modules, and a step to the new-function checklist: a built-in missing
from functionCapabilities.ts is not gated at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a key, the gated methods the examples call now throw a
LicenseCapabilityMissingError. Every engine built in a JSDoc @example
block of HyperFormula and of its events, and in the API reference
landing page, now gets licenseKey: 'gpl-v3', as the guide examples
already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The license error used to stop them first. moveColumns moved a column
to where it already was, getNamedExpressionValue passed a sheet name as
the scope, changeNamedExpression changed a global expression that did
not exist, and getAllNamedExpressionsSerialized added a name that is
not a valid named-expression name. The expected results in their
comments now match what the examples return.

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

The undo history records operations whatever the key grants, so the
predicate answered true for an undo() that then threw. Both now answer
false when the license does not allow the UndoRedo feature, like the
isItPossibleTo* predicates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The array size predictor sized a call the license stops like any
other, so the call filled its whole spill range with #LIC!, or showed
#SPILL! instead when a cell in that range was not empty. The rule that
decides which calls the license stops now lives in
FunctionCallLicenseGate, which both the interpreter and the predictor
use; the predictor sizes a stopped call as a single cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…adds a named expression

Moving or pasting a formula across sheets copies a name the source
sheet defines locally to the global scope. It also did so for a name
defined nowhere: the placeholder kept for an undefined name became an
empty global named expression, the formula's #NAME? turned into an
empty value, and the workbook serialized a named expression its author
never added - which, with the license gate, a key without
named_expressions could not build again. Only a name the source sheet
defines locally is copied now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread CHANGELOG.md Outdated
Comment thread CHANGELOG.md Outdated
cursoragent and others added 3 commits October 7, 2026 16:14
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
@codecov

codecov Bot commented Oct 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.26%. Comparing base (5abbbd9) to head (f9819f7).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #1728      +/-   ##
===========================================
- Coverage    97.32%   97.26%   -0.07%     
===========================================
  Files          195      203       +8     
  Lines        15739    16022     +283     
  Branches      3461     3555      +94     
===========================================
+ Hits         15318    15583     +265     
- Misses         413      431      +18     
  Partials         8        8              
Files with missing lines Coverage Δ
src/ArraySize.ts 100.00% <100.00%> (ø)
src/BuildEngineFactory.ts 100.00% <100.00%> (ø)
src/Config.ts 94.64% <100.00%> (+0.52%) ⬆️
src/Emitter.ts 100.00% <ø> (ø)
src/HyperFormula.ts 97.40% <ø> (-2.35%) ⬇️
src/Operations.ts 98.94% <100.00%> (+<0.01%) ⬆️
src/error-message.ts 100.00% <100.00%> (ø)
src/errors.ts 100.00% <100.00%> (ø)
src/helpers/licenseKeyValidator.ts 100.00% <100.00%> (+9.67%) ⬆️
src/index.ts 100.00% <100.00%> (ø)
... and 12 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.

@sequba
sequba merged commit 3a9c34b into develop Oct 7, 2026
34 of 37 checks passed
@sequba
sequba deleted the hf-307-entitlement-gating-pr1 branch October 7, 2026 16:34
@sequba sequba mentioned this pull request Oct 8, 2026
4 of 13 tasks
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.

6 participants