Skip to content

HF-307: align getRegisteredFunctionNames with the license gate, deprecate its static form - #1743

Merged
sequba merged 64 commits into
hf-307-entitlement-gating-pr1from
hf-307-registered-function-names
Sep 24, 2026
Merged

sequba merged 64 commits into
hf-307-entitlement-gating-pr1from
hf-307-registered-function-names

Conversation

@marcin-kordas-hoc

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

Copy link
Copy Markdown
Collaborator

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.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.


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.

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


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 HF-307: license key reader (entitlement key format, rev 6), resolution, and capability table #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.

marcin-kordas-hoc and others added 13 commits August 11, 2026 12:12
Task 2.1: LicenseCapabilityMissingError in src/errors.ts, mirroring the
existing ~30 error classes; private ensureCapability(feature) in
HyperFormula.ts mirroring ensureEvaluationIsNotSuspended.

Task 2.2: ensureCapability wired as the FIRST statement (before argument
validation) in the ~20 methods spec'd for PR 2 - NamedExpressions
(addNamedExpression, changeNamedExpression, removeNamedExpression),
Clipboard (copy, cut, paste), Crud (addRows, removeRows, addColumns,
removeColumns, moveCells, moveRows, moveColumns, addSheet, removeSheet,
clearSheet, setSheetContent, renameSheet, setCellContents), UndoRedo
(undo, redo), Batching (batch, suspendEvaluation, resumeEvaluation).
Read-only accessors (listNamedExpressions, getNamedExpression,
getAllNamedExpressionsSerialized) are left ungated, resolving the open
"getter scope" question from the handoff: gate B's own precedent already
draws this line at mutation vs. read (it blocks calling a function, not
reading a cell's existing value), so a restricted entitlement can still
see named expressions that already exist.

Task 2.3: BuildEngineFactory.ensureNamedExpressionsCapability - same
allowsFeature(FeatureId.NamedExpressions) check, applied only when the
namedExpressions argument to buildFromSheets/buildFromSheet/buildEmpty
(the three factories buildFromArray/buildFromSheets/buildEmpty resolve
to) is non-empty. Deliberately not applied to rebuildWithConfig, which
re-serializes named expressions an already-built instance was already
allowed to create, rather than accepting them fresh from a caller.

Every ensureCapability call is a single boolean read
(config.isLicenseGateActive) on the fast path, matching gate B's
hot-path property; this ships without a real license-key payload
adapter (PR 3), so every entitlement Config can produce today is
unrestricted and the guard is a correct, independently-testable no-op
in production.

Found while writing tests: PR 1's licence.spec.ts restrictEngine() test
helper granted an empty feature set, which now also blocks the
setCellContents calls those tests use to set up their formulas, before
gate B ever runs. Fixed by having that helper grant Crud by default -
those tests are about gate B's function-level check, not this PR's Crud
feature gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Self-review of the just-opened PR found four public mutating methods that
ensureCapability never covered: swapRowIndexes, setRowOrder,
swapColumnIndexes and setColumnOrder. They permute sheet structure exactly
like moveRows/moveColumns, which were gated - so a restricted entitlement
with no Crud grant could still reorder every row and column in a sheet,
which defeats the gate for a whole class of structural mutation.

Each of the four is gated in its own right rather than relying on the
swap* method the set*Order pair delegates to, so the license error still
precedes their own argument validation (task 2.2's first-statement rule).

Also writes down where the line is drawn, because "we chose not to gate
this" was previously indistinguishable from "we forgot this":
- gated: mutations that create value (sheet, clipboard, undo history,
  named expressions)
- not gated: reads, and teardown that only removes state
  (clearClipboard, clearUndoStack, clearRedoStack, destroy) - gating
  cleanup would strand an integration mid-teardown and give a licensee
  nothing

And records the gate-A asymmetry as an invariant: ensureCapability checks
entitlement only, never key validity, which is what preserves today's
behaviour where a missing key yields #LIC! in cells but keeps the CRUD API
working. A later PR that resolves an invalid key to a restricted rather
than unrestricted entitlement would silently turn that into a breaking API
change - the note is there so that happens on purpose or not at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Cursor Bugbot flagged this on the open PR; verified and fixed.

resumeEvaluation is the only exit from a suspended engine, and
_evaluationSuspended survives rebuildWithConfig. So an instance suspended
while Batching was granted, whose entitlement then loses Batching through
updateConfig, was stuck suspended permanently: every read throws
EvaluationSuspendedError and the sole recovery path threw
LicenseCapabilityMissingError. No public escape.

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. Gating the release valve only strands the
caller, which is the same reasoning that already left teardown
(clearClipboard, clearUndoStack, clearRedoStack) ungated. Stated as 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.

Two regression tests cover it (resume works after the grant is revoked;
the engine is actually left unsuspended afterwards). Both verified by
mutation - re-adding the gate fails them.

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

Fixes three confirmed findings from an independent spec-to-ship review of PR2
(5-dimension multi-agent workflow, adversarially verified):

1. HIGH - hard-gating bypass. paste() checked only FeatureId.Clipboard, but
   CrudOperations.paste() dispatches to moveCells() internally when the clipboard
   holds a cut - the same 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. Fixed by adding a public CrudOperations.isCutClipboard()
   wrapper and gating paste() on Crud too when the clipboard holds a cut - still
   checked before argument validation, matching every other ensureCapability call.
   Verified by mutation: reverting the fix makes the new regression test fail
   (paste succeeds and actually moves the cell) with the fix reverted.

2. HIGH - packaging gap. 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 (package.json's exports map only defines "." and the i18n
   subpaths). A consumer had no supported way to catch this error by type. Fixed
   by adding it alongside the other ~30 error classes already exported there.

3. MEDIUM - documentation. Added @throws [[LicenseCapabilityMissingError]] to all
   27 gated instance methods and the 3 static build factories (buildFromArray/
   buildFromSheets/buildEmpty, which throw it via BuildEngineFactory whenever
   namedExpressions is non-empty against a restricted entitlement) - matching
   this file's own established per-method @throws convention, which every other
   exception type already follows. Also corrected the class-level @see list on
   LicenseCapabilityMissingError itself: it wrongly named resumeEvaluation (which
   this PR deliberately does NOT gate, to avoid stranding a suspended engine) and
   omitted all 17 Crud methods; it now lists every method that can actually throw
   it and explains the resumeEvaluation exclusion.

Also generalizes an earlier, narrower finding (a separate manual review had
flagged only setCellContents as untested): mutation-testing all 27 ensureCapability
call sites found 17 with zero regression protection of their own - a future
accidental removal of any one of them would ship silently, since the suite's
"one test per feature group" strategy only pinned the group representative.
Added one dedicated throw test per previously-uncovered method (setCellContents,
removeRows, addColumns, removeColumns, moveCells, moveRows, moveColumns, addSheet,
removeSheet, clearSheet, setSheetContent, renameSheet, cut, paste x3 for the
bypass fix itself, redo, changeNamedExpression, removeNamedExpression), plus one
test proving LicenseCapabilityMissingError is reachable from the public entrypoint
(importing from the package root rather than src/errors directly, which is why
the packaging gap went unnoticed by the existing suite).

Verified: tsc --noEmit clean; eslint 0 new errors; full private suite green
(511/511 suites, 6370/6373 tests, 3 pre-existing skips); the paste/cut fix and
the index.ts export both re-verified against a freshly rebuilt commonjs package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Ports the read side of the typed license key format into src/license/vendor/ as
TypeScript (allowJs is off and strict is on, so this is a port rather than a
copy). Nothing consumes it yet - the key-to-entitlement adapter follows in the
next commit.

Vendored: constants, the default schema, the six reader-side helpers of utils,
the pure-JS SHA-512, and the key-data extractor. Not vendored: key generation
and the schema validator, which are unreachable here because HyperFormula only
ever reads keys and always reads them with the default schema.

The delivery form follows the key spec's own recommendation of a vendored copy
with 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 of the upstream
sources, so drift is detectable by re-cloning and re-hashing, and lists the
deliberate divergences - notably that the extractor drops the custom-schema
parameter and additionally returns licensedProductName, since the grace period
lives on the licensed product entry and re-deriving "the first schema product
present in the payload" in the caller could drift from the rule used to derive
the expiry.

Payload fields are typed unknown: field types are checked when a key is
generated, which constrains nothing about a payload that reaches this code, so
consumers must narrow rather than trust a declared shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Before this commit a genuine typed key did not work at all. The validity check
recognizes three fixed strings and the older 25-character format; a typed key
matched none of them and fell through to INVALID, so every formula returned
#LIC! and the console warned that a paid-for key was invalid. Verified by
building an engine with a real, unexpired subscription key before touching
anything.

resolveLicense reads the key once and answers both gates from that single
reading, so they cannot disagree about what the string says. A typed key is
recognized first; anything else - gpl-v3, an older-format key, an empty string,
a typed key with a broken checksum - falls through to checkLicenseKeyValidity
completely untouched. That is what keeps existing behaviour bit-identical: the
existing function is not modified, only extracted from (notifyLicenseKeyState),
so both paths report the same states with the same wording and share the
one-warning-per-page flag rather than each getting their own.

Expiry follows the format's own rules: a key with no expiration date never
expires; trial and subscription keep working for `grace` days past an inclusive
expiration date, against the clock; a perpetual key compares its maintenance end
against the build release date, so an air-gapped install with a wrong clock is
unaffected. An unknown release date resolves to "not expired", matching what the
existing validator already does - a build that cannot tell its own age must not
start rejecting keys customers paid for.

The invariant this PR must not break is enforced here and mutation-tested: only
a VALID typed key resolves to a restricted entitlement. Missing, invalid and
expired all resolve to unrestrictedEntitlement(), for typed keys exactly as for
the older format. 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.

Full unit suite green (6260 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Replaces the single-core-token placeholder with the four function packages of
the packaging design, and teaches the adapter both payload shapes.

The membership is transcribed from that design's own per-function evidence file
rather than invented. The transcript was checked by reproducing the file's five
published counts exactly: 370 rows, and 17 / 51 / 127 / 355 cumulative plus 15
operators. Coverage was checked the other way too - all 423 registered function
ids resolve into the 370 canonical entries once HF's 53 declared aliases are
canonicalised, with zero uncovered, which is also what makes the rule that
aliases travel with their canonical function true here for free.

THIS MEMBERSHIP IS A DRAFT and is marked as such in the source. The packaging
design is still under review, with the free tier's exact contents and the
placement of several function families not yet settled. Landing it now is a
deliberate call, not a claim that it is final. capability-table.spec.ts pins the
counts so a later edit cannot drift from the evidence silently.

Both payload shapes are read, per product entry, by detecting `capabilities`:
the shipped shape (tier/addons/exp/grace, contract type from the key tag) and
the newer specified shape (capabilities/usage_until/release_until/notice/flags,
with no commercial vocabulary in the payload). The two disagree about nearly
every field, the newer one is still under review, and only the first can be
minted today, so reading both means an already-issued key keeps working
whichever way that is settled. The newer spec also contradicts itself on whether
its dates are YYYY-MM-DD strings or numeric timestamps, so both are accepted.

Commercial tier names are translated to capability tokens in the adapter, not
mirrored into the table, so the table speaks one vocabulary. An unknown tier
passes through untranslated and surfaces as an unrecognized capability rather
than being swallowed.

Grants are stored fully expanded rather than chained through `implies`: the
design states the enforcement layer must not assume a hierarchy between tokens.
Operators are granted by the core token as engine baseline, and the protected
built-ins OFFSET and VERSION are listed nowhere, since the interpreter never
gate-checks them.

Features are all still granted by the core token. The evidence covers functions
only; nothing has decided whether undo/redo or the clipboard is a paid feature,
and restricting one here would both invent a product decision and make PR 2's
ensureCapability start throwing from the CRUD API for real keys.

Full unit suite green (6272 tests). Table membership mutation-tested: moving one
function across a package boundary fails the gating tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
…l-open dates

Bugbot and a code-review pass found real defects in the previous three commits.
Each was reproduced before fixing and is now pinned by a test.

TWO CRASHES on checksum-valid keys. A key whose HyperFormula entry was `null`
rather than an object threw "Cannot read properties of null (reading 'tier')"
straight out of the Config constructor, and a numeric date outside Date's range
threw "Invalid time value" from toISOString. Both killed engine construction,
where every other malformed key merely resolves to INVALID. Every payload field
is untrusted; nothing may assume a shape now.

CUSTOM FUNCTIONS WERE GATED. `functions_4` was filled from the function registry
at run time, which swept in anything registered through registerFunctionPlugin -
putting a user's OWN function into the most expensive package and returning
#LIC! for it on every smaller licence, the opposite of decision D1. The
excel-simulator set is now enumerated statically like the other three, so the
whole table is static and a function it does not list is not gated at all,
which is exactly the treatment a custom function should get. The cost is that a
newly implemented built-in is ungated until added here, which the completeness
invariant fails on - a much better failure mode.

FAIL-OPEN DATES. An unreadable rev-5 date resolved to "never expires", turning a
minting typo into a permanent licence, while the shipped shape already rejects a
malformed `exp`. A present-but-unreadable date now invalidates the key. String
dates go through the vendored parseIsoDate, so `2027-02-30` is rejected rather
than rolling over into March and granting two extra days.

WRONG SOURCE FOR REV-5 TERMS. Dates, notice and grace were read from the
licensed product entry for both shapes, but that rule belongs to the shipped
shape; under rev 5 every product entry carries its own terms. HyperFormula now
reads its own under rev 5, and flags no longer disagree with the rest.

Also: the expired-on date now reports the first day NOT covered, the convention
the legacy validator already uses, so the two paths no longer differ by a day.

Corrected a comment that the capability-table commit had invalidated: the core
token grants operators and the API surface, NOT a usable function set, so a key
whose tokens this build does not recognize evaluates operators only and returns
#LIC! for every function, silently. That cliff is deliberate per D3 but severe;
it is now described accurately and flagged for review rather than misdescribed.

Two review findings were checked and rejected: the package arrays total
16/50/125 rather than the documented 17/51/127 because OFFSET and VERSION are
protected and deliberately excluded, and INT is an excel-simulator function in
the evidence, not a math-engine one.

Full unit suite green (6305 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
formatDate used local getters on a date built at UTC midnight, so anyone west of
UTC saw a console warning naming the day BEFORE the one their key carries.

This is pre-existing rather than new - the legacy path builds its date the same
way, from a whole number of days since the epoch - so fixing the shared helper
corrects both paths rather than leaving two conventions. No test asserts the
message text, and nothing else calls formatDate.

Verified by running the same expired key under TZ=Pacific/Midway (UTC-11),
TZ=UTC and TZ=Pacific/Kiritimati (UTC+14): all three now print "January 2,
2020" for a key whose exp is 2020-01-01, which is the first day NOT covered -
the convention the legacy path already used.

Deliberately not covered by a test: the only observable is console.warn, and
the warn-once flag is module-level and never reset, so such a test would fire
only when it happened to run first in the module registry. An order-dependent
test is worse than none here.

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

Two changes from Kuba's answers on the task (comment of 12.08):

"Feature gating should work, but the legacy keys should grant all feat:*
capabilities." An earlier revision granted all five features from CORE_TOKEN,
which made feature gating inert by construction - no typed key could ever lose
an API area. The five features now live on their own feat:* tokens (spelled
after the task's draft vocabulary), and core grants the operators alone. A
rev-5 key states its feature grants explicitly; the shipped shape - whose
vocabulary predates feature tokens and whose tiers are products sold with the
full API - is granted all five by the adapter, so an existing shipped-shape
key's API behaviour is unchanged. Legacy keys resolve to the unrestricted
entitlement, which is the carve-out Kuba named, already in place.

"One unrecognized token currently silences the ENTIRE key - this seems like an
implementation error." Confirmed and decoupled: silence now comes solely from
the key's flags. The coupling suppressed strictly more than D3 asks for - a
vocabulary mismatch would have swallowed expiry notices too.

The #LIC! cliff comment is updated to record D6-A: Kuba ratified D3 as-is
("this situation should never happen. There is no point in issuing a key if
empty capabilities.").

Tests: handsontable/hyperformula-tests#32

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

Three fixes, all from reading key spec rev 5 (CU doc 8cnjcyf-31675 page
8cnjcyf-48155, updated 12.08) against the code and running the result.

**Feature tokens are OPT-IN, not opt-out.** The previous revision granted the
five feature areas only in the shipped-shape branch, so a key was denied every
gated API area unless it explicitly named `feat:*` tokens. Two key classes that
myHOT can mint TODAY do exactly that:

- rev-5 keys. §2.2 lists HyperFormula's whole token vocabulary as `functions_1..4`,
  `spreadsheet`, `import_export` - there is NO `feat:*` entry at all. Minting the
  spec's own §2 example payload and running it: setCellContents, addRows, copy,
  undo, addNamedExpression and batch ALL threw.
- shipped-shape keys whose payload carries no usable `hyperformula` entry, i.e.
  Handsontable-only keys and keys with `hyperformula: null`. These fell outside
  the branch that did the granting, so they lost the API that `core` used to give
  them - and, being gate-A VALID, they lost it without even a console warning.

So absence of a `feat:*` token cannot mean "no features": no vocabulary in
circulation can express one. It means "this key does not talk about features",
and the task's additive-safety rule - a grant may grow, never shrink - makes the
whole gated API the only safe reading. A key that DOES name a `feat:*` token
still gets exactly the areas it names, which is what Kuba asked for ("Feature
gating should work").

**`no-console-warns` is honoured.** rev 5 is not self-consistent about the flag:
its normative table and example payload (§2.3, §2) say `no-console-warns`, its
runtime sections (§4.3, §5.2) say `silent-console`, earlier revisions said plain
`silent`. Only the last two were recognised, so a doc-conformant SaaS key printed
console warnings it had explicitly asked to suppress. All three now count.

**An unreadable `capabilities` rejects the key.** `capabilities` present but not
an array fell through to the shipped-shape branch, which was a free pass twice
over: the key gained every feature it never carried, and its rev-5 dates were
never read, so a subscription expired in 2020 resolved as perpetual. It now
returns null (INVALID), matching what the module already does for an unreadable
date and what its own doc comment promises.

Also adds `resetLicenseKeyNotificationForTests` (@internal): the warn-once flag
is module-level and never reset, which made the whole console-message path
untestable - deleting the notify call left all 6300 tests green.

Tests: handsontable/hyperformula-tests#32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
`TIER_TO_CAPABILITY_TOKEN` was an object literal, and the tier it is looked up
by comes from the payload - which is attacker-influenced, since a typed key's
checksum is an unkeyed SHA-512 that anyone can compute. An object lookup also
answers for every `Object.prototype` member, so `tier: "constructor"` resolved
to a FUNCTION and `tier: "__proto__"` to an object. Either one landed in the
capability token list, and the `feat:` scan added on 13.08 then called
`.indexOf` on it:

  TypeError: token.indexOf is not a function
    at licenseResolution.ts (Array.some) -> licenseTermsOf -> resolveLicense
    -> new Config -> HyperFormula.buildFromArray

The engine failed to CONSTRUCT. That breaks the rule this module documents and
already honours elsewhere: a malformed key produces an `invalid` verdict, never
a thrown exception. Worth being precise about the history - the unsafe lookup
predates the 13.08 change, but before it a non-string token was merely ignored
by a Map lookup; the opt-in scan is what turned it into a crash.

Fixed by making the map a `Map`, which answers only for keys actually put in it
and matches `CAPABILITY_TABLE`. It also makes the types honest: `Record<string,
string>` told TypeScript the lookup yields a string, which was the lie behind
the crash, while `Map.get` returns `string | undefined`.

Behaviour for such a key is now identical to any other unknown tier: VALID key,
token passed through, recorded as unrecognized, grants nothing (D3).

Tests: handsontable/hyperformula-tests#32

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

`releaseDateTimestamp` said it reads HT_RELEASE_DATE "so a perpetual typed key
and a legacy key agree on what 'this build' means". They do not, east of UTC.

This function uses `Date.UTC`; the legacy validator parses the same env value
with `new Date(month/day/year)`, which is LOCAL. Measured at process level:

  HT_RELEASE_DATE=10/08/2026        legacy (local)   typed (UTC)
    TZ=UTC, TZ=America/Los_Angeles      20675           20675     agree
    TZ=Asia/Tokyo                       20674           20675     differ
    TZ=Pacific/Kiritimati               20674           20675     differ

Raised by Bugbot on 12.08 and left unanswered for four days while I reported the
PR as review-clean off the check status - which it was not.

The CODE is right and stays. UTC is required for a typed key: key spec rev 5 §1.2
makes offline/online parity a hard rule, and a local clock breaks it. Legacy keeps
its local parse because legacy behaviour is frozen this release - switching it
would move the expiry verdict of already-issued keys by a day for every customer
east of UTC. So the fix is to stop the comment claiming the opposite, and to state
the consequence plainly: two customers east of UTC, one on a legacy key and one on
an equivalent typed key, can disagree by a day about whether this build is covered.
Reconciling them is a product decision.

No test accompanies this, deliberately. The property is not observable in this
suite: assigning `process.env.TZ` mid-run has no effect once the runtime resolved
its timezone (probed - UTC, Asia/Tokyo and Pacific/Kiritimati all returned an
identical timestamp inside Jest), and CI runs in UTC where both parses agree. A
test written that way passes whichever parse the source uses; I wrote one,
mutation-checked it, found it vacuous, and removed it rather than ship an assertion
that cannot fail. Pinning it needs a timezone-parameterised CI job. The reasoning
sits next to the release-axis tests so the gap stays deliberate.

Tests: handsontable/hyperformula-tests#32

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

qunabu commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 20, 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 Updated (UTC)
❌ Deployment failed
View logs
hyperformula-docs a8ffef3 Sep 24 2026, 11:48 AM

@github-actions

github-actions Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Performance comparison of head (a8ffef3) vs base (6502eab)

                                     testName |    base |    head | change
--------------------------------------------------------------------------
                                      Sheet A |  441.78 |  447.14 | +1.21%
                                      Sheet B |  139.52 |  141.67 | +1.54%
                                      Sheet T |  123.83 |  122.72 | -0.90%
                                Column ranges |  574.43 |  576.51 | +0.36%
                                Sorted lookup | 18030.1 | 18279.1 | +1.38%
Sheet A:  change value, add/remove row/column |   12.51 |    12.6 | +0.72%
 Sheet B: change value, add/remove row/column |  119.75 |  123.06 | +2.76%
                   Column ranges - add column |  153.08 |  153.15 | +0.05%
                Column ranges - without batch |  479.31 |  476.97 | -0.49%
                        Column ranges - batch |  120.33 |  124.25 | +3.26%

@marcin-kordas-hoc
marcin-kordas-hoc marked this pull request as ready for review August 20, 2026 12:54
@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the hf-307-registered-function-names branch from b727d70 to d314b32 Compare August 20, 2026 13:09
@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the hf-307-registered-function-names branch from d314b32 to 0f210ad Compare August 21, 2026 01:32
@marcin-kordas-hoc marcin-kordas-hoc changed the title HF-307: align getRegisteredFunctionNames with the license gate, drop its static form (9/9) HF-307: align getRegisteredFunctionNames with the license gate, deprecate its static form (9/9) Aug 21, 2026
@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the hf-307-registered-function-names branch from 0f210ad to dcf9354 Compare August 21, 2026 02:14

@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 dcf9354 together with the paired tests at a406fc76. I checked the instance registry and translation snapshots, license filtering, aliases, custom and protected functions, raw translation behavior, static API compatibility and deprecation, documentation, changelog, and current automated findings. The focused paired run passes 17 suites (317 tests), TypeScript passes, and all current engine checks are green. I found no additional material issue in this PR.

marcin-kordas-hoc and others added 4 commits August 26, 2026 03:08
…grace

`validityOf` computes the real deadline as `usage_until + 1 day + grace`, then reported
`usage_until + 1 day` as the day the key expired, dropping the grace interval entirely.
The comment two lines above already promised "the first day NOT covered", which is the
convention the legacy validator uses, so the code disagreed with its own contract for
every key carrying a positive grace.

Measured on the stack head before the change: a key with usage_until 2027-08-12 and
grace 90 stops working on 2027-11-11, and the console said "expired on August 13, 2027" -
byte-identical to the message the same key produces with grace 0. After the change the
two cases print November 11 and August 13 respectively.

No validity decision reads `expiredOn`; its only consumer is the console message, so this
changes what we tell the customer, not what the gate allows.

Reported by Tobiadefami on this PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The licence comments carried two things this repository has no precedent for: the
first name of a colleague attached to business decisions, and the ids of internal
planning documents. Neither is resolvable by anyone reading the published package,
and both were about to enter the public history permanently - a squash merge puts
each branch's final content on develop, so removing them further up the stack would
not have helped.

The substance is untouched: every comment still says what was decided and why, and
the HF-nnn task references stay, since those already appear in this repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`getAvailableFunctions` and `getFunctionDetails` read straight from the function
registry, with no license filter, while the interpreter gates the same functions
per call. A restricted key therefore advertised functions that return `#LIC!`
when called - the exact failure removing the static metadata methods (HF-349)
was meant to prevent, left half-delivered because the instance methods never
learned to read the key their rationale said they could.

Both now filter through `licenseListsFunction`, which shares one
`licenseAllowsFunction` rule with the interpreter rather than spelling the same
condition out twice, and canonicalises aliases the same way. Extracting that
rule is the point: two copies would drift, and the drift is invisible until a
customer's picker offers a function that fails.

Gate B only, deliberately - never the validity state. A missing, invalid or
expired key resolves to an unrestricted entitlement (the invariant), so it
reaches the filter with `unrestricted` set and keeps the whole catalogue. That
falls out of the invariant rather than being a second decision, and it is the
useful answer: narrowing to the two protected built-ins would hand an integrator
who has not wired up their key yet an empty function picker and no clue why.
The list narrows only for a *valid* key that genuinely excludes a function.

Also documents `#LIC!` in types-of-errors.md, which listed only key problems and
not "function not in your package", and adds the CHANGELOG entry the feature has
not carried so far - PRs 1-3 were internals by design.

The guide deliberately documents the mechanism, not the package contents: HF-306
is still in review with six open questions, so publishing the lists now would
put moving targets in the public docs.

Tests: handsontable/hyperformula-tests#33

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Re-derived every function's lowest package straight from the 21 fun:<family>.<A|B|C>
group tokens in CU doc 8cnjcyf-33175/8cnjcyf-47835 and re-partitioned MATH_ENGINE_FUNCTIONS,
CALCULATED_FIELDS_FUNCTIONS, SPREADSHEET_FUNCTIONS and EXCEL_SIMULATOR_FUNCTIONS to match.

No function was added or removed (353 total, before and after) - only reassigned to its
correct tier. The prior table was materially stale: missing 6/22/50 functions at the three
lower tiers respectively, with some (e.g. INT, STDEV.S) sitting a tier too high.

OFFSET and VERSION remain deliberately excluded from every list: both are named by the doc
but are protected built-ins outside the token system today (see hf-306-token-vocabulary-final
memory for the two different root causes and what closing each would take - out of scope here).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
marcin-kordas-hoc and others added 22 commits September 22, 2026 12:50
The throws tag still said paste() refuses without the Crud feature when the
clipboard holds a cut. That check was dropped when the bypass was accepted, so
the tag documented an exception the method cannot raise, three lines above a
comment explaining why it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guide no longer documents what a proprietary key looks like, and the
changelog entry carried the same description in the same words. It says what
changed and what is unaffected instead.

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

The changelog gained a Removed section for 3.4.0 explaining that the static
function-metadata methods went away, but the guide's release notes for the same
version were left with only Added and Fixed. The release process treats the two
as mirrors, so a reader on the docs site could not see the breaking change at
all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A key can run out along two axes and only one of them is about the build in
use. A maintenance key stops covering releases, so an older version keeps
working and installing one is the fix; a usage-based key stops being valid at
all, and telling its holder the key "is not valid for the installed version"
sends them to downgrade, which changes nothing.

Both went through one message, because the wording predates the usage axis this
work introduced. The axis now reaches the message, and the classic
25-character format keeps the old wording, being release-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges hf307-fix/pr2 (unrestricted -> functions/features axis split) into
this PR3 branch. One conflict in CapabilityRegistry.ts's resolve() JSDoc,
where PR3 had independently added the case-insensitive token matching
paragraph (normalizeCapabilityToken) - kept both, combined into one
paragraph.

Also fixes a JSDoc line in HyperFormula.ts (licenseGrantsFunction) that
named the removed `licenseCapabilities.unrestricted` field; it now
describes both axes being set to 'all'. No other file in this PR reads
ResolvedCapabilities.functions/.features directly - getRegisteredFunctionNames
and the metadata API route through allowsFunction/licenseAllowsFunction,
which already handle the new shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The licenseGrantsFunction JSDoc still named the removed
licenseCapabilities.unrestricted field; the edit was made on disk in the
same session as the CapabilityRegistry.ts merge conflict but not staged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Following the review's answer to which reading applies: the engine knows what
each capability token grants and nothing about which tokens make up a package.

- The package tokens functions_1..4 are gone, with the three group lists and
  cumulative grants that defined them. A package reaches the engine only as the
  list of group tokens a generator writes into the key, so a key's functions are
  the union of the tokens it names. Measured against the packaging document's
  own membership lists, every package keeps exactly the functions it had.
- The always-granted core token is gone. The operator callable forms such as
  HF.ADD are now gated like any other function, through fun:operator.a or their
  own single-function token, as the review asked. The infix operators are not
  function calls and never reach the entitlement check, so they work under any
  key.
- fun:all now also covers the operator callable forms, since it names the whole
  catalog.

The ordering note on the table is dropped: the registry's reverse index is only
ever consulted to ask whether a function is covered at all, so which token
covers it first no longer matters.

The upstream generator's default schema still words HyperFormula packages as
functions_1..4, so a key minted from it grants no function until that schema
emits group tokens instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The review settled that getRegisteredFunctionNames() answers for the registry,
and registration is not entitlement, so a restricted key must not shorten it.
The instance method is back to listing every function registered in the
instance, byte for byte its behaviour on develop, and the registry accessor
this pull request had removed is restored.

What remains is the deprecation of the static method, with its rationale
rewritten, because the licence no longer supplies one. The measured reason: a
static method has no engine in scope, so it answers for the global registry,
while an engine configured with its own functionPlugins registers only those.
Built with a single plugin, an engine lists two names while the static method
lists 423, SUM among them, which that engine cannot evaluate at all.

The guide now says the registry listing is unaffected by the key, and the
changelog drops the entry describing the filtering.

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

The invariant no longer has a core token to fall back on, and the spec it
cites lives in the paired test repository, which the comment now says.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…er shipped

The retroactive 3.4.0 entry announced, as a breaking change, the removal of the
static getAvailableFunctions() and getFunctionDetails(). Neither was ever
released: both arrived in #1692 and left again in #1724, inside the 3.4.0
development cycle, and no tag carries them. The entry told every upgrader about
a break that cannot affect them, so it is withdrawn from the changelog, and the
copy of it in the guide's release notes goes too. Release notes are written at
release time, not during development, so that file is back to develop's.

The deprecation note on the static getRegisteredFunctionNames() leaned on the
same false history; it now says the metadata methods were instance-only from
their first release.

The guide's sentence on getRegisteredFunctionNames() was a continuation line
of the bullet before it, so it rendered inside the claim it contradicts. It is
its own bullet now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sequba added a commit that referenced this pull request Sep 24, 2026
…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>
Base automatically changed from hf-307-entitlement-gating-pr3 to hf-307-entitlement-gating-pr1 September 24, 2026 11:45
@sequba
sequba merged commit 5b8c91b into hf-307-entitlement-gating-pr1 Sep 24, 2026
27 of 29 checks passed
@sequba
sequba deleted the hf-307-registered-function-names branch September 24, 2026 11:48
@codecov

codecov Bot commented Sep 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.17%. Comparing base (6502eab) to head (a8ffef3).
⚠️ Report is 3 commits behind head on hf-307-entitlement-gating-pr1.

Additional details and impacted files

Impacted file tree graph

@@                        Coverage Diff                        @@
##           hf-307-entitlement-gating-pr1    #1743      +/-   ##
=================================================================
- Coverage                          97.39%   95.17%   -2.23%     
=================================================================
  Files                                204      204              
  Lines                              16256    16256              
  Branches                            3602     3602              
=================================================================
- Hits                               15833    15472     -361     
- Misses                               415      744     +329     
- Partials                               8       40      +32     
Files with missing lines Coverage Δ
src/HyperFormula.ts 99.05% <ø> (-0.72%) ⬇️

... and 11 files with indirect coverage changes

🚀 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 added a commit that referenced this pull request Oct 7, 2026
…, API guards (#1728)

### 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](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!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.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f9819f7. 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>
Co-authored-by: Kuba Sekowski <jakub.sekowski@handsontable.com>
Co-authored-by: Kuba Sekowski <kuba.sekowski.dev@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
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.

4 participants