Skip to content

HF-307 PR 2: public API guards (ensureCapability) - #1729

Open
marcin-kordas-hoc wants to merge 4 commits into
developfrom
hf-307-entitlement-gating-pr2
Open

HF-307 PR 2: public API guards (ensureCapability)#1729
marcin-kordas-hoc wants to merge 4 commits into
developfrom
hf-307-entitlement-gating-pr2

Conversation

@marcin-kordas-hoc

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

Copy link
Copy Markdown
Collaborator

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

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

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.

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

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. Worth reading Bugbot's inline comments directly rather than trusting an aggregator.

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 - confirmed against a clean HyperFormula.ts run).
  • 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.
  • Private test suite pushed: hyperformula-tests#31 - unit/license/public-api-guards.spec.ts (21 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, and two for the resumeEvaluation deadlock) 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 fail if the gate is re-added, and the repaired custom-function test fails if the exemption is removed. Both were run.
  • Full suite against the paired branch: 506 suites / 6225 tests, 6222 passed, 3 pre-existing skips, 0 failures.

Types of changes

  • Breaking change
  • New feature or improvement
  • 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:

  • 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)
  • My changes require a documentation update. (no - nothing user-visible yet)
  • My changes require a migration guide. (no)

Note

Medium Risk
Wide changes across many public API entry points and license resolution; incorrect gating would block core spreadsheet operations once restricted keys ship in a later PR.

Overview
Adds HF-307 PR 2 entitlement checks on the public API so restricted licenses can block feature areas before work runs, not only at formula evaluation.

HyperFormula gets private ensureCapability(FeatureId) (fast path when isLicenseGateActive is false) wired as the first statement on CRUD, clipboard, undo/redo, batching, and named-expression mutators; failures throw new LicenseCapabilityMissingError. BuildEngineFactory mirrors that for non-empty serialized named expressions on buildFromSheets / buildFromSheet / buildEmpty, but not rebuildWithConfig.

Config now holds resolved licenseCapabilities, isLicenseGateActive, and a CapabilityRegistry (still resolving unrestrictedEntitlement() until the PR 3 key adapter). The interpreter’s license gate switches to that model and checks per-function entitlements (with alias canonicalization) via ErrorMessage.LicenseCapability. New src/license/* defines FeatureId, grants, and allowsFeature / allowsFunction.

Until a real restricted entitlement can be produced, these guards stay effectively no-ops in production—same as the stacked PR 1 interpreter gate.

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
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 <[email protected]>
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 c29a9ba Commit Preview URL

Branch Preview URL
Aug 11 2026, 01:19 PM

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Performance comparison of head (c29a9ba) vs base (61ead73)

                                     testName |   base |   head | change
------------------------------------------------------------------------
                                      Sheet A | 307.61 | 298.86 | -2.84%
                                      Sheet B | 103.58 | 101.67 | -1.84%
                                      Sheet T |  90.05 |  86.78 | -3.63%
                                Column ranges | 324.24 | 322.96 | -0.39%
                                Sorted lookup | 9298.2 | 9873.3 | +6.19%
Sheet A:  change value, add/remove row/column |   10.3 |  11.09 | +7.67%
 Sheet B: change value, add/remove row/column | 103.56 |  97.83 | -5.53%
                   Column ranges - add column | 101.72 | 105.77 | +3.98%
                Column ranges - without batch | 332.27 | 326.37 | -1.78%
                        Column ranges - batch |  85.65 |  82.92 | -3.19%

@marcin-kordas-hoc
marcin-kordas-hoc marked this pull request as ready for review August 11, 2026 12:34

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

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 6689397. Configure here.

Comment thread src/HyperFormula.ts Outdated
marcin-kordas-hoc and others added 2 commits August 11, 2026 13:05
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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.33%. Comparing base (61ead73) to head (c29a9ba).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #1729      +/-   ##
===========================================
+ Coverage    97.31%   97.33%   +0.01%     
===========================================
  Files          195      198       +3     
  Lines        15719    15835     +116     
  Branches      3455     3473      +18     
===========================================
+ Hits         15297    15413     +116     
  Misses         414      414              
  Partials         8        8              
Files with missing lines Coverage Δ
src/BuildEngineFactory.ts 100.00% <100.00%> (ø)
src/Config.ts 94.73% <100.00%> (+0.61%) ⬆️
src/HyperFormula.ts 99.76% <100.00%> (+<0.01%) ⬆️
src/error-message.ts 100.00% <100.00%> (ø)
src/errors.ts 100.00% <100.00%> (ø)
src/interpreter/Interpreter.ts 95.51% <100.00%> (+0.14%) ⬆️
src/license/CapabilityRegistry.ts 100.00% <100.00%> (ø)
src/license/LicenseEntitlement.ts 100.00% <100.00%> (ø)
src/license/capabilities.ts 100.00% <100.00%> (ø)
🚀 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.

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.

2 participants