feat: Kagaku.Flow templating — a sandboxed Scriban host, template-derived sinks, the budgeting contract (Phase 2 · slice 5b) #19
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/kagaku-flow-templating"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Phase 2, slice 5, second half, on top of #18. It adds the engine core's templating host, the template validation pass, and the budgeting contract. Slice 6 (the scheduler) renders templates through it before each execution. Slice 8's
llmdescriptor derives its sinks from its message templates and implementsIBudgetedConsumer. Slice 9 addsstory.*as a host extension.Nothing here runs a graph or changes what a user sees.
What
Kagaku.Flow.TemplatingParsedTemplate.Parse(path, text)never throws. It returns the template, its derived sinks and itsTemplateProblems (kind, message, line, column).inputs.x,inputs?.x,inputs["x"]andinputs['x']each make a sinkx, in order of first appearance and compared case-sensitively.story.*,run.*,item,index,loop.*and host roots never make a sink.TemplateHost.RenderAsync(template, request)renders asynchronously and carries the request'sServices, so slice 9'sstory.*reads through the execution's scope. It uses a freshTemplateContextand fresh builtins per render; one shared context garbled 1,998 of 2,000 parallel renders in the probe.TemplateError(path, message, line, column, and theExceptionwhen a .NET exception surfaced). A cancel always throws with the caller's token.inputs.<sink>.run.id,run.started_at(a UTCDateTime, because aDateTimeOffsetbreaksdate.to_string) andrun.trigger(the trigger kind as a string).ITemplateModelExtension), called once per render in registration order. They may add roots but never shadow a core root;inputs,run,item,index,loopandvalueare reserved.TemplateRun.OwnerIdand the trigger envelope are visible to extensions only, never as template values, so Tsumugi's target chapter does not leak into the core'srun.src/Kagaku.Flow/README.md:includeandinclude_joinare removed from the builtins, andimportis refused by a runtime override and a static rule.with,this,object.eval*, a write throughinputsand every non-static use ofinputsare refused statically, because each defeats sink derivation.StrictVariablesis on.MaxChars(8,388,608) makes over-long output fail instead, andLimitToStringstays on because it is also Scriban's allocation guard.NodeDescriptor.Templates(config)returnsTemplateField(Path, Text), which is how the engine renders before an executor runs without knowing thellmconfig's shape. A hook that returns one path twice isdescriptor-failed.ValidationCodes. It reports each field's problems at its node, with the JSON Pointer to the field and the problem's line and column. It is a tenth pass in the pinned order, andValidationOrderTestscovers it.Kagaku.Flow.Budgeting:IBudgetedConsumer(ADR 0017), found throughNodeRegistry.FindBudgetedConsumer. Given aBudgetRequest(the consumer node, the sink the context slot feeds, the other inputs so far, andServices), a consumer answers with aConsumerBudget: the model id, the window tokens, the reserved output, and the overhead text its template adds. It is asynchronous, because thellmanswer needs the catalog. It returns the model id, because the chars-per-token ratio is per model.OtherInputsmay be partial; absent inputs render empty, and the overhead is then a lower bound.AGENTS.md's new-node-type checklist gains the template fields.Decisions to confirm (Björn)
with,this,for kv in inputsorinputs | object.keysis a validation error. Each hides which sinks a template reads, and ADR 0015 derives sinks from templates. That makes the sandbox a subset of ADR 0015's "Scriban everywhere".valueis reserved now, not in Phase 3 withbranch. Reserving it later would break any host that had taken the name meanwhile.textand required, matching today's canvas descriptors.IBudgetedConsumer's shape goes beyond the plan's wording: it is asynchronous, returns the model id, is told which sink is the slot, and receivesServices.Tests
4cc0cea, after #18)Kagaku.Flow's 483 are 477 facts and theory rows plus the 6
[Property]tests from #18. New test files:ParsedTemplateTests,TemplateRenderTests,TemplateModelTests,TemplateSandboxTests,TemplateLimitTests,TemplateRuleTestsandBudgetedConsumerTests.They pin, among other things:
Draftanddraftas two sinks;== nullon a JSON null;D3formatting an integer input;Build: the full CI build (with the module's npm build) has 0 warnings. Three full-solution runs were all green. One Integration test failed once during verification, in a parallel run; it did not reproduce in the three runs since, and this PR changes no Tsumugi code.
Coverage: coverlet on facts and theories alone (
--filter "Category!=Property"), per file, line and branch. All 45 source files ofsrc/Kagaku.Flow, 1,916 lines, are at 100% line and branch. The only partials are in the regex source generator's output, as in #18.Mutation probes: the brief's templating list, run on a copy, one at a time, restored and
touched, then re-run after the review round. The survivors each got a test and are killed: shared builtins, case-folded sinks, JSON null as"", decimal before long, a dropped cancel token, and a droppedServicesorTriggerJson, which now fails an assertion, not a crash. 21 more against the review round's new code all died: the depth and chain bounds, the render-time stack check, the null guards and the repeated path. Three redundant checks that could never change an outcome were deleted rather than justified.Out of scope
llmdescriptor and its templates: slice 8.story.*: slice 9.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A sandboxed Scriban host~ You have no idea how happy this makes me. Most people slap a template engine in and call it a day — you built a static analyzer over the AST for sink derivation, a runtime sandbox that assumes the analyzer is wrong, and a stack-guard story because you actually killed your own test host with a recursive template and said so in the PR body. That's the kind of obsessive I approve of~ ♡
Verdict: ✅ Looks good to me~
What I verified myself (CI had no bot comment for
2ef5335at review time, so everything below is local):0 warnings / 0 errorswith-warnaserror, full solution, both submodules initialized.Category!=Property, same method as #18): all 45 source files ofsrc/Kagaku.Flowat 100% line AND branch; the only partials are the regex source-generator'sRegexGenerator.g.csoutputs — exactly as claimed._seenfolded toOrdinalIgnoreCase→Sink_names_are_case_sensitivered.isMemberNameguard oninclude→A_member_named_include_is_not_the_builtinred (and only it).IsReachableVariableto "anything that parses" → 9 red inTemplateModelTests— the single-statement check is load-bearing.OperationCanceledExceptionrethrow → both cancellation tests red.include/include_joinin the builtins → both sandbox-removal tests red.decimalbeforelonginTemplateValues.Number→ themath.format 'D3'row red. (This one bit me too — my first coverage run used a stale mutated DLL and reproduced the failure as a false alarm. Rebuilt both projects, green. The stale-binary discipline in my notes exists for a reason~)✅ What I liked~
RenderUncheckedAsyncis the internal seam that lets tests prove each runtime layer alone. Defense in depth with honest boundaries between the layers — my favorite kind of design.TryEnsureSufficientExecutionStackat every evaluation step, because Scriban checks only at calls/statements and resolves chains unchecked. The test that runs the killer recursion on a 1 MiB thread so it runs short regardless of host stack size — fufu, that's beautiful. AndLimitChainwalks up the chain bounded, so the 200,000-link probe costs O(21) per link, not the walk's stack.IsReachableVariable. Instead of enumerating Scriban's keywords, you ask Scriban: does{{ name }}parse as exactly one read of that global? Thein-is-a-variable test pins the subtlety. Probe 3 proves the strictness isn't decorative.run.started_atas UTCDateTimewith the reason (date.to_stringrefusesDateTimeOffset) in a comment and a test that feeds +02:00 and asserts 08:00:00. Correctness claims that come with their own falsification.WireText.Malformedreused for the UTF-16 refusal rather than re-implemented — sibling-consistent (G10), and the pointer-in-message contract (at '/a/1') is tested at three nesting shapes.Templatesjoins the V1 hook guards (throw, null field, null text, repeated path — case-difference-is-two-paths pinned),ValidationOrderTestsgains the pass in the pinned ladder, andHostRuleTestsproves rules seeTemplatesOfwith the same by-reference semantics asSocketsOf.ParsedTemplate,{{ string.leak = 1 }}vanishing by the next render, top-level assignments dying with their render. Fresh context and fresh builtins — both halves pinned.BudgetedTypedoubles as the pattern document for slice 8'sllmdescriptor: realTemplateHost, slot rendered empty,OtherInputsmerged, stateless descriptor. The lower-bound-overhead semantics ("absent inputs render empty") is stated in the contract where the caller will read it.Nothing blocking, and I looked hard — the walker's
inputshandling (member-name vs target, writes,empty?, dynamic index), the error taxonomy (which failures carry anExceptionand why that must not be read as transient), theMaxChars + 1boundary (with the one-past-allowed/two-past-fails pair), the limits constructor guards. All tested, all directional.The five "Decisions to confirm" are yours to bless, Björn — the sandbox-subset reading of ADR 0015 (decision 1) is coherent with sink derivation, and I'd take it, but it's a product-visible restriction so it's your call, same as #18's decision 1.
Ready to merge~
Automated review by Jibril · 2026-09-23
CI/CD: absent for head SHA
2ef5335at review time (no bot comment) · Local checks: build 0w/0e -warnaserror, 903/903, coverage + 6/6 mutation probes killed, tree pristine at2ef5335Summary
Summary
Coverage
Kagaku.Flow - 99.4%
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__ColorToken_2
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__Socket_1
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__TypeId_0
Tsumugi.BlazorAdapter - 96.9%
Tsumugi.Domain - 99.3%
Tsumugi.Infrastructure - 97.1%
A55E0373ED22DC58420DBB40F103A46B68DC4D9CE5A49E00B__Pattern_0
on
tion
n
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 100%
Tsumugi.UseCases - 98%