Spec: Per-package KCL config fragments — service-owned defaults (db, embeddednats, embeddedfga) #14
Labels
No labels
needs-info
needs-triage
ready-for-agent
ready-for-human
wayfinder:grilling
wayfinder:map
wayfinder:prototype
wayfinder:research
wayfinder:task
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
ginjiruu/toolbelt#14
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Problem Statement
Each toolbelt service package (
db,embeddednats,embeddedfga) owns default values for its data locations (NATSdata/nats, SQLitedata/sqlite/<app>.db, FGAfile://data/fga/openfga.db), but those defaults are hand-written in two places today: as Go consts/values in the service package and asschema DB/schema NATS/schema FGAblocks repeated in every app'sschema.k. Every app re-declares the service schemas it uses, and the platform's default for a service can drift from what apps ship. The composition root (toolbelt/app) reads platform fields by name, but today each app hand-wires that wiring ininternal/app/app.go.Apps should be able to compose a service's config with one line (
nats: NATS = NATS {}) and inherit the service's schema and defaults without re-declaring them — and the service package should own its own defaults in exactly one place.Solution
Service packages ship their own KCL fragment:
db/schema.k,embeddednats/schema.k,embeddedfga/schema.k, plus a toolbelt-owned core fragmentapp/schema.k(app identity, logging, session, rebuild,addr). Apps compose them via native KCL imports (import toolbelt.embeddednats) in theirschema.k, so the schema the app authors is the schema that is validated and the defaults the service ships are the defaults the app inherits. The universalLogschema is no longer embedded in the tool — it moves into theappfragment.kcl-generateresolves these imports at build time instead of string-merging fragment sources; the runtime.kloader resolves the same imports viakcl.mod. Service packages drop their hand-written Go const defaults; the composition root always passes the resolved field value.This ticket reshapes
kcl-generate+example/; consumer migration (go-template, ttrpg) is a separate later project.User Stories
schema.k.nats: embeddednats.NATS = embeddednats.NATS {}), so that the service's fields and defaults appear in my generated config without hand-copying.kcl-generateto fail loudly when a required field (e.g.db.path) is not set, so that I cannot ship an app with a missing data location.Logschema to come from the platform's own fragment rather than the tool's embedded copy, so that platform-owned fields live with the platform.SetupNATSto take the store directory as a required parameter, so that the composition root always passes the resolved field value and no hidden Go default survives.toolbelt/appdoes not depend on app-generated field symbols.nats:,db:,fga:,log:,session:,rebuild:), so that the composition root's path-based reads cannot be silently broken by a renamed key.New()when a required platform field is absent, so that misconfiguration surfaces at startup instead of as an empty string.kcl.modto reference the toolbelt fragments at the same revision as the Go module, so that published pseudo-version consumers resolve fragments correctly.File/Tupleschemas to ride theembeddedfgafragment, so that tuple-sync config is service-owned.NATS,DB,FGA,Log) preserved, so that my schema composes naturally via qualified imports.option()available for genuine per-fragment knobs but not used as the app-identity mechanism, so that app identity stays visible in the schema composition.Implementation Decisions
KCL-native fragment imports (Q1). Each service package ships a fragment file in its own directory (
db/schema.k,embeddednats/schema.k,embeddedfga/schema.k), plus a toolbelt-owned core fragmentapp/schema.k. The app'sschema.kimports them viaimport toolbelt.<pkg>and composes vianats: embeddednats.NATS = embeddednats.NATS {}.kcl-generateresolves these imports (build-time) instead of string-merging fragment sources; the runtime.kloader resolves the same imports viakcl.mod. No generated/merged schema file is emitted — the authored schema.k is the runtime schema.Universal
Logmoves toapp/schema.k(Q5). The tool's embeddeduniversal_schema.kis removed. Theappfragment owns app identity (name),Log,Session,Rebuild, andaddras service-neutral or identity defaults. Resolves the coordination from ticket #4 (toolbelt/app owns the composition-root bootstrap).Service-neutral vs app-owned defaults (Q3, Q8, Q12). Fragments ship service-neutral defaults (NATS
data/nats, FGAfile://data/fga/openfga.db,mode=embedded,datastore=sqlite, thecheck:block). App identity values (SQLite filename, FGAstore_id) live in the app's composition overrides:db: DB = DB { path = "data/sqlite/kanban.db" }. A prototype showed KCLoption()does not observe the app's composition value, sooption()is not the identity mechanism — explicit composition overrides are.db.pathhas no default at all: the app must compose it, andkcl-generatefails loudly if it is absent.Schema names preserved (Q14). Fragment schema names stay
NATS,DB,FGA,Log. The KCL-qualified import (embeddednats.NATS) disambiguates; no renaming churn for consumers.Collision semantics (Q6). App wins by simply not importing a fragment and defining its own schema. Fragment-vs-fragment schema name collisions are KCL module-level errors. No merge-precedence code in
kcl-generate.app/schema.kownership and scope (Q4, Q11, Q15). This ticket ships fragments fordb,embeddednats,embeddedfga, and theappcore fragment.File/Tuple(FGA tuples) rideembeddedfga/schema.k. TheOIDCschema stays app-authored in this ticket;authn/schema.k(declaring the standardOIDCschema) is ticket #4's responsibility — a coordination comment was posted on #4.Field-path contract (Q10, Q13).
toolbelt/appreads platform fields via typed path readers against the app-local registry (theconfig.Newobject from ticket #3). A prototype showed the alternatives are unbuildable: sharing toolbelt-owned*Fieldpointers across apps mutates shared state at init; path-string lookup alone silently degrades on unknown paths. Therefore: canonical composition keys (nats:,db:,fga:,log:,session:,rebuild:) are enforced at codegen, andtoolbelt/appfails loudly atNew()when a required platform field is absent. Recorded as the #4↔#12 coordination inapp-bootstrap.md.embeddednatsGo API (Q7).SetupNATS(ctx, logger, storeDir string)— the store directory becomes a required positional parameter.DefaultStoreDirconst and theWithStoreDiroption are removed.app/init.godrops the empty-string guard. ttrpg's twoWithStoreDir(t.TempDir())test calls become positional.kcl.modwiring (Q2, Q9). Fragment discovery needs no flag list: the app declares its fragments viaimport+kcl.mod. A Taskfile stamp step derives the toolbelt fragment reference fromgo list -mand rewrites the toolbelt entry inkcl.mod, sokcl.modcannot drift fromgo.mod— including for published pseudo-version consumers.Toolbelt does not import downstream (Q3). Fragments may import other toolbelt fragments but never the app's schema; toolbelt never depends on a downstream app.
No runtime merging (decision 3, revised). The runtime
.kloader already evaluates the config program and now resolves schema imports viakcl.mod— same resolution path as codegen. What dies is string-merging at codegen and any runtime notion of merging. The generatedvar DefaultXldflags-overridable pattern survives: defaults are still evaluated at codegen into Go vars.The
#@kcl:validate = "ValidateFGAURI"directive rides the FGA fragment and referencesconfig.ValidateFGAURI(resolvable because the app importstoolbelt/config). No new mechanism.Testing Decisions
Good tests here exercise external behavior: a schema.k plus fragment files plus a
kcl.modin a temp dir, in → generated fields/config out (or a loud error out). They must not reach intokcl-generate's internals beyond what the existing tests already exercise (parse → collect → evaluate → render pipeline).Primary seam:
tools/kcl-generate/main_test.go(existing prior art:TestFlattenSchema,TestEvaluateDefaults,TestRenderConfigStruct,TestFieldCollision,TestUnknownDirective). Add tests that drive the full pipeline against a schema.k that imports fragment files:nats.store_dir→data/nats);jetstream: NATS = NATS {}) fails codegen;db.pathfails codegen;NATSandkcl-generateaccepts it);Secondary seam:
config/kcl/kcl_test.go(existing prior art:TestLoadKConfigWithOptions,TestLoadKConfigRelativePath,TestValidateConfig). Add one test proving a config file → schema.k → fragment import chain resolves at runtime load (the authored schema.k is the validated schema).Integration: the example's
check-kcldrift guard (existing Taskfile task). After theexample/reshape,task check-kclregenerates and fails on stale output, proving a real app composes fragments end-to-end.Out of Scope
authn/schema.k(theOIDCfragment) — owned by ticket #4; coordination comment posted on #4.toolbelt/app's typed path readers andNew()fail-loud behavior — the mechanism is specified here as a contract, but the implementation lands with ticket #4 (composition-root bootstrap) which depends on ticket #3'sconfig.Newobject.config.Newobject bootstrap — ticket #3's decision; this ticket's fragments feed the same field registry shape it already expects.Further Notes
option()in schema defaults does not observe the app's composition value — confirming explicit composition overrides as the identity mechanism; (b) field-path read mechanisms — confirming that shared toolbelt-owned*Fieldpointers are unbuildable (shared mutable state) and that typed path readers with fail-loud are the correct contract.All sub-tickets landed and closed: #15 (embeddednats positional store dir), #16 (kcl-generate fragment import resolution + composition contract enforcement), #17 (fragments shipped, example composes them, runtime validation), #18 (kcl.mod sync stamp step). Out-of-scope follow-ups tracked separately: #4 (composition-root typed readers / New fail-loud), authn/schema.k, consumer migration.
Lessons learned (post-implementation)
All four sub-tickets (#15–#18) landed and closed; commit
f3cbcd0on master.Beyond the disposition note above, here is what the implementation surfaced that
the spec did not fully anticipate — worth reading before #4 (composition-root
bootstrap) and the consumer migration.
Deviations from the spec's mechanism
kcl-go does not read
kcl.mod[dependencies]itself. The spec said"the runtime
.kloader resolves the same imports viakcl.mod". Inpractice
kcl.Runignores the dependencies table: both the generator'sevaluateDefaultsand the runtime loader must explicitly pass the deps askcl.WithExternalPkgAndPath(name, path)(and validation viaValidateCodeArgs.ExternalPkgs). Also, the external module root — thetoolbelt checkout — must ship its own
kcl.moddeclaringname = "toolbelt"or the resolution fails with "CannotFindModule".
addrownership forced schema inheritance. A root-level scalar fieldcannot be shipped by a fragment unless the app's root schema inherits it.
The app fragment owns the root
Configschema and apps writeschema Config(app.Config).kcl-generategained parent-schema flattening(module-qualified parent resolution so the child's
Configresolves to thefragment's, not itself) — this was not called out in the decision list.
KCL key/package shadowing.
db: db.DB = db.DB {}is a compile error(the attribute key
dbshadows the importdb). The db fragment must bealiased:
import toolbelt.db as tdb. The other fragments compose unaliasedbecause their canonical keys (
nats,fga,log) differ from theirpackage names (
embeddednats,embeddedfga,app). The spec's examplenats: embeddednats.NATS = embeddednats.NATS {}works for exactly thisreason.
Required-field enforcement came for free.
path: strandstore_id: str(no default) are type-checked by KCL itself: an instancethat omits them errors with "attribute is required and can't be None or
Undefined". The "fail loudly" user stories ride evaluation rather than extra
generator logic. Both
db.pathandfga.store_idare required by design;the example composes both explicitly.
Smaller findings
The example's
kcl.modusespath = "../"(not"../../") because theexample lives inside the toolbelt repo. The #18 stamp step derives the path
from
go list -m -f '{{.Dir}}'so it cannot drift; for published consumersthe same step stamps the module-cache path.
Merging fragment files broke
#@kcl:directive correlation — line numberscollide across files. Fields and directives are now correlated per source
file.
kcl.mod parsing was first duplicated across
tools/kcl-generateandconfig/kcl, and the two copies already drifted (one swallowed errors, theother didn't). The "separate modules, can't share" justification was wrong —
tools/go.modalready requires the root module — so it was consolidatedinto the shared
kclmodpackage. Lesson: check module dependency directionbefore duplicating a helper.
Group labels were preserved verbatim from the pre-fragment example (e.g. the
dbfragment keeps the odd"Server"group) purely to keep the generatedfields byte-identical and
task check-kclgreen. Revisit if a rename isever wanted.
Consumer break recorded: ttrpg's two
SetupNATS(..., WithStoreDir(t.TempDir()))test calls become positional —noted in the
SetupNATSdoc comment. Migration is the separate laterproject.
Forward pointers
kclmod(git.animeteamspeak.moe/ginjiruu/toolbelt/kclmod) is the singlehome for path-dep resolution; reuse it in any new consumer tooling rather
than re-parsing
kcl.mod.kcl-generate(canonicalKeys). Adding a newcanonical fragment schema means touching that map — consider a
fragment-declared
#@kcl:keydirective if the set grows.schema.k(Filevariant +
ExternalPkgs), so "the schema you author is the schema that isvalidated" holds end to end.