Skip to content

feat: enforce runtime State/Response/Event hierarchy at compile time#166

Open
mttrbrts wants to merge 7 commits into
mainfrom
feat/enforce-runtime-type-hierarchy
Open

feat: enforce runtime State/Response/Event hierarchy at compile time#166
mttrbrts wants to merge 7 commits into
mainfrom
feat/enforce-runtime-type-hierarchy

Conversation

@mttrbrts

@mttrbrts mttrbrts commented Jul 12, 2026

Copy link
Copy Markdown
Member

What

Makes the TypeScript compilation of template logic enforce the runtime class hierarchy: a template's declared state / response / emitted event must actually extend the corresponding Concerto runtime base type. Previously a template could declare its "state" as a plain concept ... identified (rather than asset ... extends State) and the engine would compile and run it happily.

Why

Two root causes made the prior type checking ineffective:

  1. The injected runtime declarations bound the state type to a maximally-permissive structural interface IState { $identifier: string }. Under structural typing, any identified concept satisfies it — so the hierarchy was never actually checked.
  2. Hidden second bug: template logic imports its model types from ./generated/<namespace>, but the compilation context emitted those model files at the root (<namespace>.ts). The imports never resolved in the twoslash sandbox, every model type collapsed to any, and any satisfies every constraint — so no type checking could fire at all.

How

Derive, from the template's own Concerto model, the union of concrete (non-abstract) subclasses of each runtime base type, and use those unions as the bounds/positions in the injected TemplateLogic declarations. When a template declares no valid subtype for a base, its union is never, so using a non-conforming type fails to type-check. This mirrors cicero-core's getStateTypes() / getRequestTypes() / getResponseTypes() / getEmitTypes(), but applies it as a TypeScript bound rather than a per-invocation runtime check — so it costs nothing at runtime.

src/TypeScriptCompilationContext.ts

  • New buildRuntimeUnion(baseFqn, aliasPrefix){ imports, union } over the concrete subclasses of baseFqn, excluding the base type itself, or never when there are none.
  • getRuntimeDeclarations(): the engine now emits the runtime declarations itself (with model-derived bounds) instead of prepending the base64 .d.ts:
    • RuntimeState / RuntimeRequest / RuntimeResponse = concrete subclasses of the respective org.accordproject.runtime@0.2.0.* base.
    • RuntimeEvent = concrete subclasses of the base concerto@1.0.0.Event (deliberately the base Event, not Obligation — plain events are valid emits).
    • TemplateLogic is a real abstract class with a stub init body, so it still emits runtime JS and does not raise TS2391.
  • getCompilationContext(): emits generated model files under generated/<namespace>.ts (fixes root cause Release markdown-transform #2).

src/TemplateArchiveProcessor.ts

  • compileLogic() no longer prepends SMART_LEGAL_CONTRACT_BASE64 (the context supplies those declarations now).
  • After compiling the logic entry file only, it throws on the hierarchy-violation diagnostic TS2344. Scoped to TS2344 + the logic entry so README/generated scripts don't trip it and existing templates with other benign diagnostics stay green.

test/StateHierarchy.test.ts (new)

5 tests: rejects a plain-concept state (TS2344), accepts a state extending State, backward-compat for a plain-event template, rejects a response not extending Response (TS2322), rejects an event not extending base Event (TS2322).

Testing

  • Full suite 74/74 passing (includes 2 new request-enforcement tests); eslint clean on changed files.
  • 4 snapshots legitimately updated (context now has the generated/ prefix + the Runtime* unions; two TemplateMarkInterpreter snapshots shifted line/offset only).

Reviewer notes / known limitations

Update — bare base types allowed; hierarchy enforced nominally at runtime

Request/Response/State are concrete (not abstract) in the runtime model — only Obligation is abstract — so a template may legitimately use the bare base type. The enforcement was reworked so bare base types are accepted while a plain concept that does not extend the base is still rejected:

  • Compile time: buildRuntimeUnion now includes the base in the State/Response/Event union (drops the base-exclusion filter). Bare base or any subclass type-checks. Response/Event still reject a plain concept structurally (the base carries the $timestamp discriminant a concept lacks). State carries only $identifier, so an identified concept is structurally indistinguishable from the base and can no longer be rejected at compile time.
  • Runtime: the model-level request check was removed (vacuous once the concrete base is allowed). Added assertRuntimeHierarchy in trigger/init: after serialization, assert each payload's $class is, or extends, the runtime base for its role (request/state in, state/result/events out). This is nominal — it accepts the bare base and any subclass and rejects a non-extending concept, giving the protection the type system cannot (request is a bivariant parameter; state is structurally satisfied by any identified concept).
  • Fixture: the latedelivery archive's own state was a plain concept … identified (the bug pattern) — changed to asset … extends State, since the runtime check would otherwise (correctly) reject it.
  • Existing templates in cicero-template-library (e.g. helloworldstate, latedeliveryandpenalty) still carry @ts-expect-error / @ts-ignore on their extends TemplateLogic<...> lines that suppress the new diagnostics. These were only added because the runtime types didn't previously resolve; they're now obsolete and should be removed in that repo.
  • Dead code removed: SMART_LEGAL_CONTRACT_BASE64 (the bundled runtime TemplateLogic declarations) is no longer used for logic compilation — the compilation context emits those declarations with model-derived bounds — so it is dropped from declarations.ts and no longer generated. src/slc/SmartLegalContract.d.ts is kept for the public TemplateLogic type re-exported by src/index.ts.

🤖 Generated with Claude Code

@mttrbrts
mttrbrts force-pushed the feat/enforce-runtime-type-hierarchy branch from 7b088f7 to bb05c3a Compare July 12, 2026 18:33
Derive RuntimeState/RuntimeRequest/RuntimeResponse/RuntimeEvent unions from
the template's Concerto model (concrete subclasses of the runtime base types)
and use them as the bounds/positions in the injected TemplateLogic
declarations. A "state" that is a plain concept (not an asset extending
State) now binds to `never` and fails to compile (TS2344); likewise an
invalid response or emitted event fails in return position (TS2322).

Also emit generated model files under `generated/` so the logic's own
`./generated/<ns>` imports resolve in the twoslash sandbox - previously they
did not, so all model types were `any` and no type checking occurred.

compileLogic now surfaces the TS2344 hierarchy violation as a hard error
(scoped to the logic entry file) instead of silently ignoring diagnostics.

Known limitation: request is not enforceable via the input parameter
(TypeScript method parameters are bivariant); some templates still suppress
the errors with @ts-expect-error and should be updated separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: mttrbrts <code@rbrts.uk>
@mttrbrts
mttrbrts force-pushed the feat/enforce-runtime-type-hierarchy branch from bb05c3a to 7998d3a Compare July 12, 2026 18:34
@coveralls

coveralls commented Jul 12, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 29432082590

Coverage increased (+1.3%) to 54.981%

Details

  • Coverage increased (+1.3%) from the base build.
  • Patch coverage: 2 uncovered changes across 1 file (49 of 51 lines covered, 96.08%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
src/TemplateArchiveProcessor.ts 23 21 91.3%
Total (3 files) 51 49 96.08%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1274
Covered Lines: 770
Line Coverage: 60.44%
Relevant Branches: 874
Covered Branches: 411
Branch Coverage: 47.03%
Branches in Coverage %: Yes
Coverage Strength: 2283.89 hits per line

💛 - Coveralls

@mttrbrts
mttrbrts force-pushed the feat/enforce-runtime-type-hierarchy branch from 911f238 to 03b2ba7 Compare July 12, 2026 19:02
Two follow-ups to the runtime hierarchy work:

1. Request enforcement (was unenforceable at compile time). The runtime
   Request hierarchy cannot be checked by the type system: `request` appears
   only as the `trigger` parameter, and TypeScript method parameters are
   bivariant, so the model-derived `never` bound accepts any type (State,
   Response and Event are caught because they sit in constraint / return
   positions). compileLogic now asserts the invariant against the model: when
   the logic defines a `trigger`, the model must declare a concrete subclass of
   org.accordproject.runtime@0.2.0.Request.

   Note the base Request/Response/State types are *concrete* (not abstract) in
   the runtime model, so a base-inclusive query (getRequestTypes) always lists
   the base and can never be empty. We therefore query with the base excluded
   via findConcreteSubclassNames(fqn, true) - the same base-excluding semantics
   used by cicero-core's isStateful() and by the RuntimeRequest union in the
   compilation context. A "request" declared as a plain concept yields an empty
   list and is rejected.

2. Remove the now-unused SMART_LEGAL_CONTRACT_BASE64. The runtime
   TemplateLogic/EngineResponse declarations are emitted directly by
   TypeScriptCompilationContext (with model-derived bounds), so the bundled
   base64 copy is dead. Dropped it from declarations.ts and stopped generating
   it in scripts/updateRuntimeDependencies.js. src/slc/SmartLegalContract.d.ts
   is kept: src/index.ts still re-exports the public TemplateLogic type from it.
   dayjs / jsonpath base64 (still consumed by TypeScriptToJavaScriptCompiler)
   are unchanged.

Tests: 2 new TemplateArchiveProcessor cases (valid request compiles; empty
request subtypes rejected) that also pin the base-concreteness pitfall. Full
suite 74/74 passing, eslint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Matt Roberts <code@rbrts.uk>
@mttrbrts
mttrbrts force-pushed the feat/enforce-runtime-type-hierarchy branch from 03b2ba7 to eed77d6 Compare July 12, 2026 19:12
… nominally at runtime

The runtime Request/Response/State bases are concrete (only Obligation is
abstract), so a template may legitimately use the bare base type. This reworks
the enforcement so bare base types are accepted while a plain concept that does
not extend the base is still rejected.

Compile time (TypeScriptCompilationContext):
- buildRuntimeUnion now *includes* the base type in the State/Response/Event
  union (getConcreteRuntimeTypes drops the base-exclusion filter, keeping the
  abstract filter). Using the bare base or any subclass type-checks. Response
  and Event still reject a plain concept structurally, because the base carries
  the $timestamp transaction/event discriminant that a concept lacks. State
  carries only $identifier, so an identified concept is structurally
  indistinguishable from the base - it can no longer be rejected at compile time.

Runtime (TemplateArchiveProcessor):
- Removed the model-level request check (it was vacuous once the concrete base
  is allowed - getRequestTypes always lists the base).
- Added assertRuntimeHierarchy: after serialization, assert each payload's
  $class is, or extends, the runtime base for its role (request/state on input,
  state/result/events on output). This is nominal, so it accepts the bare base
  and any subclass and rejects a plain concept - the protection the type system
  cannot give for request (bivariant parameter) or state (structural $identifier).
  Events bind to the base Concerto Event (plain events and Obligations both pass).

Fixture: the latedelivery archive's own state was a plain
`concept LateDeliveryAndPenaltyState identified` - the exact bug pattern, which
the new runtime check would (correctly) reject. Changed it to
`asset LateDeliveryAndPenaltyState extends State` and updated the generated
interface it imports.

Tests: StateHierarchy updated for the base-inclusive union (plain-concept state
now type-checks; response still rejected via missing $timestamp). Added a
runtime test that a Response passed as the request is rejected. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Matt Roberts <code@rbrts.uk>
Comment thread src/TemplateArchiveProcessor.ts Outdated
Comment thread src/TypeScriptCompilationContext.ts Outdated
@github-actions github-actions Bot added the maintainer-engaged A maintainer has commented or reviewed this item label Jul 13, 2026
…archy error

- Move the runtime base-type FQN constants (State/Request/Response/Event) into
  src/utils.ts and import them from both TypeScriptCompilationContext and
  TemplateArchiveProcessor, removing the duplicated declarations.
- Broaden the compile-time hierarchy error message to name State, Request,
  Response and Event (was "State and Obligation" only), and drop the outdated
  Obligation-centric wording now that events bind to the base Concerto Event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Matt Roberts <code@rbrts.uk>
@devanshi00

Copy link
Copy Markdown
Contributor

Suggestion: extract shared "assignable type" logic into utils.ts

getConcreteRuntimeTypes (TypeScriptCompilationContext.ts) and the inline lookup in assertRuntimeHierarchy (TemplateArchiveProcessor.ts) both answer the same question — "is-a baseFqn" — via independent calls to getAssignableClassDeclarations(), each with its own try/catch. One builds a compiler union, the other does runtime membership-testing, but the assignability rule should have one source of truth.

This PR's history makes the case: the semantics changed twice mid-review (base-exclusion → inclusion, model-level → runtime-only), and both call sites needed hand-updating each time. Suggest centralizing in utils.ts, same as was done for the FQN constants:

// utils.ts
export function getAssignableConcreteTypes(modelManager: ModelManager, baseFqn: string): ClassDeclaration[] {
  let baseType;
  try {
    baseType = modelManager.getType(baseFqn);
  } catch {
    return [];
  }
  return baseType.getAssignableClassDeclarations().filter(decl => !decl.isAbstract());
}

export function isAssignableTo(modelManager: ModelManager, fqn: string, baseFqn: string): boolean {
  return getAssignableConcreteTypes(modelManager, baseFqn)
    .some(decl => decl.getFullyQualifiedName() === fqn);
}
  • buildRuntimeUnion → use getAssignableConcreteTypes(...)
  • assertRuntimeHierarchy → use isAssignableTo(...)

Guarantees compile-time and runtime checks agree by construction, and gives one place to unit-test the rule directly.

@devanshi00

devanshi00 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

init() fails on stateless templates

Error Trace

✖  FAILED: Cannot read properties of undefined (reading 'state')
TypeError: Cannot read properties of undefined (reading 'state')
    at TemplateArchiveProcessor.init (/Users/devanshichhatbar/Documents/template-engine/lib/TemplateArchiveProcessor.js:314:26)
    at async run (/Users/devanshichhatbar/Documents/template-engine/examples/stateless/car-rental-tr/run.js:149:24)
    at async test (/Users/devanshichhatbar/Documents/template-engine/examples/stateless/car-rental-tr/run.js:180:5)

Cause: For templates with no compiled init logic, executeTypeScriptInit() returns a placeholder { state: {} }. init() then unconditionally calls serializer.fromJSON(initResponse.state)

…nit crash

Addresses review feedback from @devanshi00:

1. Single source of truth for the runtime type hierarchy. Both the compile-time
   union builder (TypeScriptCompilationContext.getConcreteRuntimeTypes) and the
   runtime membership check (TemplateArchiveProcessor.assertRuntimeHierarchy)
   independently answered "is-a baseFqn" via getAssignableClassDeclarations()
   with their own try/catch. Extracted getAssignableConcreteTypes() and
   isAssignableTo() into utils.ts and use them from both sites. (Concerto-core has
   no equivalent helper - its ModelUtil.isAssignableTo is a private
   property-assignment check with different semantics.)

2. init() no longer crashes on stateless templates. executeTypeScriptInit()
   returns an empty placeholder `{ state: {} }` when there is no compiled init;
   init() then called serializer.fromJSON on that classless {}. Skip serialization
   of a state with no $class (same guard already used by assertRuntimeHierarchy),
   in init output, trigger output, and trigger input (a stateless init state
   chained into trigger). Added a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Matt Roberts <code@rbrts.uk>
@mttrbrts

Copy link
Copy Markdown
Member Author

Thanks @devanshi00, both addressed in 0a431d3:

1. Shared assignable-type logic — extracted getAssignableConcreteTypes(modelManager, baseFqn) and isAssignableTo(modelManager, fqn, baseFqn) into utils.ts (essentially your proposed shape), and both TypeScriptCompilationContext (union builder) and TemplateArchiveProcessor.assertRuntimeHierarchy (runtime check) now call them — one source of truth for the hierarchy rule. I checked whether concerto-core already exposes this: it doesn't — the only ModelUtil.isAssignableTo is a @private property-assignment check with a different signature/semantics, so a local util is the right call.

2. init() on stateless templates — good catch. Guarded the state serialization on the presence of $class so the empty placeholder { state: {} } is skipped (matching the guard assertRuntimeHierarchy already uses). Applied to init output, trigger output, and trigger input too (a stateless init state chained into trigger would hit the same path). Added a regression test.

Reference accordproject/concerto#1281, which proposes adding these
class-hierarchy assignability helpers to Concerto's model API. The local
implementation stays until that lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Matt Roberts <code@rbrts.uk>
@mttrbrts
mttrbrts marked this pull request as ready for review July 15, 2026 16:08
@mttrbrts
mttrbrts requested review from a team and Copilot July 15, 2026 16:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR tightens the contract between Concerto runtime models and TypeScript template logic by ensuring that a template’s declared State/Request/Response/Event types align with the runtime class hierarchy (via model-derived compile-time unions plus nominal $class checks at runtime), and fixes a long-standing issue where generated model imports didn’t resolve in the twoslash sandbox.

Changes:

  • Generate model-derived Runtime* union bounds in the injected TypeScript compilation context (and emit generated models under generated/ so logic imports resolve).
  • Remove the bundled SmartLegalContract base64 declarations from the logic compiler path and add runtime $class hierarchy assertions in init/trigger.
  • Add/adjust tests, fixtures, and snapshots to validate hierarchy enforcement and new compilation context layout.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
test/TemplateArchiveProcessor.test.ts Adds runtime request-hierarchy regression test and ensures stateless init placeholder doesn’t get serialized.
test/StateHierarchy.test.ts New compiler-focused tests covering compile-time diagnostics for response/event and state behavior.
test/archives/latedeliveryandpenalty-typescript/model/model.cto Fixes the fixture state to extend State and imports State.
test/archives/latedeliveryandpenalty-typescript/logic/generated/io.clause.latedeliveryandpenalty@0.1.0.ts Updates generated interfaces to use IState instead of a concept-shaped state.
test/snapshots/TypeScriptCompiler.test.ts.snap Snapshot updates reflecting injected runtime TemplateLogic JS output.
test/snapshots/TypeScriptCompilationContext.test.ts.snap Snapshot updates for generated/ paths and injected Runtime* declarations.
test/snapshots/TemplateMarkInterpreter.test.ts.snap Snapshot offsets updated due to added injected declarations in the compilation context.
src/utils.ts Introduces runtime base FQNs and reusable model-assignability helpers.
src/TypeScriptCompilationContext.ts Builds model-derived runtime unions and fixes twoslash filesystem layout to match ./generated/<namespace> imports.
src/TemplateArchiveProcessor.ts Removes base64 declaration prepending, hard-fails on TS2344 (logic entry), and adds runtime hierarchy checks; adjusts serialization behavior for stateless placeholders.
src/runtime/declarations.ts Removes unused SMART_LEGAL_CONTRACT_BASE64 export.
scripts/updateRuntimeDependencies.js Stops bundling SmartLegalContract declarations; keeps only dayjs/jsonpath typings packaging.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/TemplateArchiveProcessor.ts Outdated
Comment thread src/TemplateArchiveProcessor.ts Outdated
Comment thread src/TemplateArchiveProcessor.ts Outdated
Comment thread src/TypeScriptCompilationContext.ts Outdated
Comment thread src/TemplateArchiveProcessor.ts Outdated
Comment thread src/TemplateArchiveProcessor.ts Outdated
…chy docs

Addresses Copilot review feedback:

- State validation now skips only the empty `{}` placeholder returned by a
  stateless template's init (checked via Object.keys length), instead of any
  state lacking $class. A non-empty classless state is validated normally and
  rejected. Applied to init output, trigger output and trigger input; added a
  regression test.
- Updated stale doc/comments that still described the pre-change behaviour:
  the getRuntimeDeclarations docstring and injected comment (no longer a `never`
  union for a concrete base) and the compileLogic comments (State/Request/
  Response/base Event, not "State / Obligation"). Snapshots refreshed for the
  injected-comment text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Matt Roberts <code@rbrts.uk>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer-engaged A maintainer has commented or reviewed this item

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants