From 66893971a0cf72cd4d130381e9bad09096c16bd9 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 11 Aug 2026 12:12:03 +0000 Subject: [PATCH 1/3] HF-307 PR 2: public API guards (ensureCapability) 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 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/BuildEngineFactory.ts | 27 +++++++++++++++++++++-- src/HyperFormula.ts | 45 +++++++++++++++++++++++++++++++++++++++ src/errors.ts | 25 ++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/BuildEngineFactory.ts b/src/BuildEngineFactory.ts index 62202a78c1..69b74eaea3 100644 --- a/src/BuildEngineFactory.ts +++ b/src/BuildEngineFactory.ts @@ -10,7 +10,7 @@ import {Config} from './Config' import {CrudOperations} from './CrudOperations' import {DateTimeHelper} from './DateTimeHelper' import {DependencyGraph} from './DependencyGraph' -import {SheetSizeLimitExceededError} from './errors' +import {LicenseCapabilityMissingError, SheetSizeLimitExceededError} from './errors' import {Evaluator} from './Evaluator' import {Exporter} from './Exporter' import {GraphBuilder} from './GraphBuilder' @@ -19,6 +19,8 @@ import {ArithmeticHelper} from './interpreter/ArithmeticHelper' import {FunctionRegistry} from './interpreter/FunctionRegistry' import {Interpreter} from './interpreter/Interpreter' import {LazilyTransformingAstService} from './LazilyTransformingAstService' +import {allowsFeature} from './license/CapabilityRegistry' +import {FeatureId} from './license/LicenseEntitlement' import {buildColumnSearchStrategy, ColumnSearchStrategy} from './Lookup/SearchStrategy' import {NamedExpressions} from './NamedExpressions' import {NumberLiteralHelper} from './NumberLiteralHelper' @@ -50,23 +52,44 @@ export type EngineState = { export class BuildEngineFactory { public static buildFromSheets(sheets: Sheets, configInput: Partial = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState { const config = new Config(configInput) + this.ensureNamedExpressionsCapability(config, namedExpressions) return this.buildEngine(config, sheets, namedExpressions) } public static buildFromSheet(sheet: Sheet, configInput: Partial = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState { const config = new Config(configInput) + this.ensureNamedExpressionsCapability(config, namedExpressions) const newsheetprefix = config.translationPackage.getUITranslation(UIElement.NEW_SHEET_PREFIX) + '1' return this.buildEngine(config, {[newsheetprefix]: sheet}, namedExpressions) } public static buildEmpty(configInput: Partial = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState { - return this.buildEngine(new Config(configInput), {}, namedExpressions) + const config = new Config(configInput) + this.ensureNamedExpressionsCapability(config, namedExpressions) + return this.buildEngine(config, {}, namedExpressions) } public static rebuildWithConfig(config: Config, sheets: Sheets, namedExpressions: SerializedNamedExpression[], stats: Statistics): EngineState { return this.buildEngine(config, sheets, namedExpressions, stats) } + /** + * Throws if `namedExpressions` is non-empty and `config`'s entitlement does not grant + * {@link FeatureId.NamedExpressions} (HF-307 PR 2, task 2.3 - the build-time counterpart of + * {@link HyperFormula.ensureCapability}). An empty list is never checked: building an engine + * with no named expressions never touches the feature. Deliberately not called from + * {@link rebuildWithConfig}, which re-serializes named expressions an already-built instance + * created (and was allowed to create) rather than accepting them fresh from a caller. + */ + private static ensureNamedExpressionsCapability(config: Config, namedExpressions: SerializedNamedExpression[]): void { + if (namedExpressions.length === 0) { + return + } + if (config.isLicenseGateActive && !allowsFeature(config.licenseCapabilities, FeatureId.NamedExpressions)) { + throw new LicenseCapabilityMissingError(FeatureId.NamedExpressions) + } + } + private static buildEngine(config: Config, sheets: Sheets = {}, inputNamedExpressions: SerializedNamedExpression[] = [], stats: Statistics = config.useStats ? new Statistics() : new EmptyStatistics()): EngineState { stats.start(StatType.BUILD_ENGINE_TOTAL) diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index 522d7b1509..e0fd1dc6d4 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -38,11 +38,14 @@ import { ExpectedValueOfTypeError, LanguageAlreadyRegisteredError, LanguageNotRegisteredError, + LicenseCapabilityMissingError, NotAFormulaError, } from './errors' import {Evaluator} from './Evaluator' import {ExportedChange, Exporter} from './Exporter' import {LicenseKeyValidityState} from './helpers/licenseKeyValidator' +import {allowsFeature} from './license/CapabilityRegistry' +import {FeatureId} from './license/LicenseEntitlement' import {buildTranslationPackage, RawTranslationPackage, TranslationPackage} from './i18n' import {FunctionPluginDefinition} from './interpreter' import {FUNCTION_DOCS} from './interpreter/functionMetadata' @@ -1237,6 +1240,7 @@ export class HyperFormula implements TypedEmitter { * @category Undo and Redo */ public undo(): ExportedChange[] { + this.ensureCapability(FeatureId.UndoRedo) this._crudOperations.undo() return this.recomputeIfDependencyGraphNeedsIt() } @@ -1275,6 +1279,7 @@ export class HyperFormula implements TypedEmitter { * @category Undo and Redo */ public redo(): ExportedChange[] { + this.ensureCapability(FeatureId.UndoRedo) this._crudOperations.redo() return this.recomputeIfDependencyGraphNeedsIt() } @@ -1407,6 +1412,7 @@ export class HyperFormula implements TypedEmitter { * @category Cells */ public setCellContents(topLeftCornerAddress: SimpleCellAddress, cellContents: RawCellContent[][] | RawCellContent): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) this._crudOperations.setCellContents(topLeftCornerAddress, cellContents) return this.recomputeIfDependencyGraphNeedsIt() } @@ -1824,6 +1830,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public addRows(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.addRows(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -1896,6 +1903,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public removeRows(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.removeRows(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -1972,6 +1980,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public addColumns(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.addColumns(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -2047,6 +2056,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public removeColumns(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.removeColumns(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -2140,6 +2150,7 @@ export class HyperFormula implements TypedEmitter { * @category Cells */ public moveCells(source: SimpleCellRange, destinationLeftCorner: SimpleCellAddress): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) if (!isSimpleCellAddress(destinationLeftCorner)) { throw new ExpectedValueOfTypeError('SimpleCellAddress', 'destinationLeftCorner') } @@ -2226,6 +2237,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public moveRows(sheetId: number, startRow: number, numberOfRows: number, targetRow: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(startRow, 'number', 'startRow') validateArgToType(numberOfRows, 'number', 'numberOfRows') @@ -2314,6 +2326,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public moveColumns(sheetId: number, startColumn: number, numberOfColumns: number, targetColumn: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(startColumn, 'number', 'startColumn') validateArgToType(numberOfColumns, 'number', 'numberOfColumns') @@ -2352,6 +2365,7 @@ export class HyperFormula implements TypedEmitter { * @category Clipboard */ public copy(source: SimpleCellRange): CellValue[][] { + this.ensureCapability(FeatureId.Clipboard) if (!isSimpleCellRange(source)) { throw new ExpectedValueOfTypeError('SimpleCellRange', 'source') } @@ -2392,6 +2406,7 @@ export class HyperFormula implements TypedEmitter { * @category Clipboard */ public cut(source: SimpleCellRange): CellValue[][] { + this.ensureCapability(FeatureId.Clipboard) if (!isSimpleCellRange(source)) { throw new ExpectedValueOfTypeError('SimpleCellRange', 'source') } @@ -2443,6 +2458,7 @@ export class HyperFormula implements TypedEmitter { * @category Clipboard */ public paste(targetLeftCorner: SimpleCellAddress): ExportedChange[] { + this.ensureCapability(FeatureId.Clipboard) if (!isSimpleCellAddress(targetLeftCorner)) { throw new ExpectedValueOfTypeError('SimpleCellAddress', 'targetLeftCorner') } @@ -2769,6 +2785,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public addSheet(sheetName?: string): string { + this.ensureCapability(FeatureId.Crud) if (sheetName !== undefined) { validateArgToType(sheetName, 'string', 'sheetName') } @@ -2844,6 +2861,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public removeSheet(sheetId: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') const displayName = this.sheetMapping.getSheetName(sheetId) as string this._crudOperations.removeSheet(sheetId) @@ -2917,6 +2935,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public clearSheet(sheetId: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.clearSheet(sheetId) return this.recomputeIfDependencyGraphNeedsIt() @@ -2984,6 +3003,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public setSheetContent(sheetId: number, values: RawCellContent[][]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.setSheetContent(sheetId, values) return this.recomputeIfDependencyGraphNeedsIt() @@ -3671,6 +3691,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public renameSheet(sheetId: number, newName: string): void { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(newName, 'string', 'newName') const oldName = this._crudOperations.renameSheet(sheetId, newName) @@ -3712,6 +3733,7 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public batch(batchOperations: () => void): ExportedChange[] { + this.ensureCapability(FeatureId.Batching) this.suspendEvaluation() this._crudOperations.beginUndoRedoBatchMode() try { @@ -3759,6 +3781,7 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public suspendEvaluation(): void { + this.ensureCapability(FeatureId.Batching) this._evaluationSuspended = true this._emitter.emit(Events.EvaluationSuspended) } @@ -3795,6 +3818,7 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public resumeEvaluation(): ExportedChange[] { + this.ensureCapability(FeatureId.Batching) this._evaluationSuspended = false const changes = this.recomputeIfDependencyGraphNeedsIt() this._emitter.emit(Events.EvaluationResumed, changes) @@ -3902,6 +3926,7 @@ export class HyperFormula implements TypedEmitter { * @category Named Expressions */ public addNamedExpression(expressionName: string, expression: RawCellContent, scope?: number, options?: NamedExpressionOptions): ExportedChange[] { + this.ensureCapability(FeatureId.NamedExpressions) validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -4124,6 +4149,7 @@ export class HyperFormula implements TypedEmitter { * @category Named Expressions */ public changeNamedExpression(expressionName: string, newExpression: RawCellContent, scope?: number, options?: NamedExpressionOptions): ExportedChange[] { + this.ensureCapability(FeatureId.NamedExpressions) validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -4205,6 +4231,7 @@ export class HyperFormula implements TypedEmitter { * @category Named Expressions */ public removeNamedExpression(expressionName: string, scope?: number): ExportedChange[] { + this.ensureCapability(FeatureId.NamedExpressions) validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -4767,6 +4794,24 @@ export class HyperFormula implements TypedEmitter { } } + /** + * Throws an error if the current license entitlement does not grant the given feature. + * A no-op read (`isLicenseGateActive === false`) whenever this instance's entitlement is + * unrestricted, i.e. for every key this library fully understands today (HF-307 PR 1); the + * check only does work once a real license-key payload adapter (a later HF-307 PR) can + * produce a restricted entitlement. + * + * @internal + */ + private ensureCapability(feature: FeatureId): void { + if (!this._config.isLicenseGateActive) { + return + } + if (!allowsFeature(this._config.licenseCapabilities, feature)) { + throw new LicenseCapabilityMissingError(feature) + } + } + /** * Parses a formula string and extracts its AST and dependencies. * diff --git a/src/errors.ts b/src/errors.ts index 66a73a2add..7f762947c0 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -4,6 +4,7 @@ */ import {SimpleCellAddress} from './Cell' +import {FeatureId} from './license/LicenseEntitlement' /** * Error thrown when the sheet of a given ID does not exist. @@ -392,3 +393,27 @@ export class AliasAlreadyExisting extends Error { super(`Alias id ${name} in plugin ${pluginName} already defined as a function or alias.`) } } + +/** + * Error thrown when a public API method is called for a {@link FeatureId} that the current + * license entitlement does not grant. Mirrors gate B's `ErrorMessage.LicenseCapability`, but + * this one guards the API surface itself (HF-307 PR 2) rather than a formula evaluation, so it + * is thrown synchronously instead of surfacing as a cell error. + * + * @see [[addNamedExpression]] + * @see [[changeNamedExpression]] + * @see [[removeNamedExpression]] + * @see [[copy]] + * @see [[cut]] + * @see [[paste]] + * @see [[undo]] + * @see [[redo]] + * @see [[batch]] + * @see [[suspendEvaluation]] + * @see [[resumeEvaluation]] + */ +export class LicenseCapabilityMissingError extends Error { + constructor(feature: FeatureId) { + super(`Feature ${feature} is not included in your license.`) + } +} From 8a26e2be758c6988bbc1784f68e43a089bc6e933 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 11 Aug 2026 13:05:43 +0000 Subject: [PATCH 2/3] HF-307 PR 2: close the Crud gate on row/column reordering 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 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/HyperFormula.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index e0fd1dc6d4..5f18b4322c 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -1465,6 +1465,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public swapRowIndexes(sheetId: number, rowMapping: [number, number][]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.setRowOrder(sheetId, rowMapping) return this.recomputeIfDependencyGraphNeedsIt() @@ -1548,6 +1549,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public setRowOrder(sheetId: number, newRowOrder: number[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') const mapping = this._crudOperations.mappingFromOrder(sheetId, newRowOrder, 'row') return this.swapRowIndexes(sheetId, mapping) @@ -1641,6 +1643,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public swapColumnIndexes(sheetId: number, columnMapping: [number, number][]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.setColumnOrder(sheetId, columnMapping) return this.recomputeIfDependencyGraphNeedsIt() @@ -1719,6 +1722,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public setColumnOrder(sheetId: number, newColumnOrder: number[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') const mapping = this._crudOperations.mappingFromOrder(sheetId, newColumnOrder, 'column') return this.swapColumnIndexes(sheetId, mapping) @@ -4801,6 +4805,22 @@ export class HyperFormula implements TypedEmitter { * check only does work once a real license-key payload adapter (a later HF-307 PR) can * produce a restricted entitlement. * + * Where the line is drawn, so a later change does not move it by accident: + * - **Gated:** methods that create value by mutating the sheet, the clipboard, the undo + * history, or the named-expression set. + * - **Not gated:** reads (`getCellValue`, `listNamedExpressions`, + * `getAllNamedExpressionsSerialized`, the `isItPossibleTo*` predicates) and teardown or + * cleanup 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, and mirrors gate B, which + * blocks *calling* a function rather than *reading* an already-computed value. + * + * Note this checks gate B (entitlement) only, never gate A (key validity). That asymmetry + * with the interpreter's gate B - which checks key validity first - is deliberate: it keeps + * today's behaviour for a missing or invalid key, where formulas yield `#LIC!` but the CRUD + * API keeps working. A later PR that resolves an invalid key to a *restricted* entitlement + * rather than an unrestricted one would silently turn that into a breaking API change. + * * @internal */ private ensureCapability(feature: FeatureId): void { From c29a9ba400bc3b893a81e308f74e9fd318488dd3 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 11 Aug 2026 13:14:57 +0000 Subject: [PATCH 3/3] HF-307 PR 2: ungate resumeEvaluation - gating it could brick an engine 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 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/HyperFormula.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index 5f18b4322c..3c4f9fbc29 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -3822,7 +3822,15 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public resumeEvaluation(): ExportedChange[] { - this.ensureCapability(FeatureId.Batching) + // Deliberately NOT gated, unlike suspendEvaluation and batch. This is the only exit from + // a suspended engine, and _evaluationSuspended survives rebuildWithConfig: an instance + // suspended while Batching was granted, whose entitlement then loses Batching via + // updateConfig, would be stuck suspended forever - every read throws + // EvaluationSuspendedError and the sole recovery path would throw + // LicenseCapabilityMissingError. Gating the two entry points is what makes the feature + // licensable; gating the release valve only strands the caller, which is the same reason + // teardown (clearClipboard, clearUndoStack, clearRedoStack) is ungated. See the note on + // ensureCapability. this._evaluationSuspended = false const changes = this.recomputeIfDependencyGraphNeedsIt() this._emitter.emit(Events.EvaluationResumed, changes) @@ -4814,6 +4822,11 @@ export class HyperFormula implements TypedEmitter { * `clearRedoStack`, `destroy`). Gating cleanup would let a restricted entitlement strand * an integration mid-teardown while giving a licensee nothing, and mirrors gate B, which * blocks *calling* a function rather than *reading* an already-computed value. + * - **Not gated, for the same reason:** `resumeEvaluation`, the sole exit from a suspended + * engine. Gate the entry points (`suspendEvaluation`, `batch`) and the feature is + * licensable; gate the release valve too and an entitlement change mid-suspension leaves + * the instance permanently unusable. A capability check must never be reachable only on + * the way out of a state it let the caller into. * * Note this checks gate B (entitlement) only, never gate A (key validity). That asymmetry * with the interpreter's gate B - which checks key validity first - is deliberate: it keeps