Generated from
cat-harness/skills/kg/kg-core/schema-management.md— do not edit here.
Managing a schema — the graph, the viewer, and the three answers that are not “fine”
This skill does not restate where schemas live or how they are laid out. directory-conventions §”What lives in the
schemas graph” owns that — the Zod .ts is authoritative, every other form
is generated, and a rendering is never where a fix lands. A rule stated twice
is a rule free to drift, and the copy a reader finds first is the one with no
test behind it.
Nor does it restate how to model. data-modelling owns
the question that matters before any of this — what are the things, and what
is true of each of them exactly once? — with three worked failures from this
repository where a fact sat on the wrong entity.
What is here is the work between those two: reading the graph the schemas form, and keeping it readable.
The schema graph is a graph, and it is published
scripts/schema-graph.ts reads every declared schemas directory and
returns declarations plus the edges between them.
scripts/gen-schema-viz.ts publishes that as a projection and a viewer.
bun run cat schema:graph prints the counts; schema:viz writes; schema:viz:check
is the gate.
It reads every declared directory, not one, and that is not a convenience.
directoryForGraph throws when several directories declare a graph — the
wggr guard, which exists because returning the first silently once resolved
cat-harness to schemas/ and wrote 37 sidecars against the wrong subjects on
a run that exited 0. Several instances declare a schemas graph, so the
singular question has no answer, and the fix is never to pick one. A view
of one of several reports a corpus that does not exist.
Resolution is scoped per directory for the same reason one level down: two
instances may each have a foo.ts, and a global index by module stem would
resolve one instance’s import to another instance’s export — the same failure,
silently, with every edge still computing.
Why the AST, and not the two things that look easier
Both obvious alternatives erase the edges a schema graph is made of, and it is the same erasure at two levels. Know this before proposing either again:
- The published JSON Schema.
harness-schema-exportrenders with$refStrategy: "none", which is correct for a dereferenceable document — a consumer following an$idgets something self-contained. The cost is that a referenced type is not a named thing in the output at all; it is an anonymous object repeated at each use site. A diagram drawn from it is disconnected boxes. - The TypeScript type checker.
z.infer<typeof FooSchema>resolves to a deeply expanded structural type, so asking whatFoo.baris returns the whole ofBarinlined rather than a reference to it.
So the syntactic form is not the compromise. It is the only one of the
three that still holds bar: BarSchema as a reference to a named thing.
The three answers that are not “fine”, and what each one means
The reader reports three things that are easy to read as noise and are not. Check them when you add or change a schema — the viewer surfaces all three.
| answer | what it means | what to do |
|---|---|---|
undetermined | the walker saw an expression it does not model, and says what it saw | usually correct and nothing to do — a bare object literal is not a Zod call. If it is a shape you expect to be modelled, the walker is missing a case. |
unresolved | a name the module BINDS, imported from a module inside this graph, that resolves to no declaration | a real finding. Usually a const array of literals imported as a vocabulary. Ask whether it wants to be a schema. |
external | a name bound to a non-relative import, or to something in this module that is not a declaration | resolved correctly, outside this graph. Nothing to do. |
undetermined is never rendered as an empty type, and that is the whole
reason it exists as a state: a declaration with no fields and a declaration
whose expression could not be read are different answers, and rendering them
alike reports a clean run over something never looked at.
The fix for noise is never a deny-list. The first version of the reader
collected every identifier and reported z as the graph’s most-referenced
missing declaration, 175 times. A list of names to ignore would have been a
second, drifting statement of this graph’s vocabulary. The fix was to ask what
the module BINDS: z is bound, to zod, which is not a relative import and
therefore not part of this graph. Distinct unresolved names fell from 150 to 6,
and the 6 are real.
What the graph structurally CANNOT see — measured, not guessed
The reader sees a reference when one schema names another. It sees nothing when
a reference is carried as a string id, because actorId: z.string() holds
no syntactic link to the schema it names. Neither would JSON Schema, and
neither would the type checker: the information is not in the types at all.
Measured against schemas/assistant-schema.puml, 266 lines of UML a person
drew by hand, used as a control:
| relation the diagram draws | reproduced by the reader |
|---|---|
composition (*--) — a nested schema | 14 of 15 |
association (-->) — a foreign key by id | 0 of 9 |
A clean split, and it names the limit exactly. So the viewer states it — permanently in the header, and again on any type carrying id-style fields — because a diagram that omitted a whole class of relation silently would be the “rendered as nothing” failure this repository works to avoid. An undrawn association is invisible, not absent.
@ref — declaring the edge the reader cannot find
Those edges are drawable, and the mechanism is a JSDoc tag on the field:
/**
* Skills available to an actor in this role, before inheritance.
*
* @ref SkillDefinitionSchema
*/
skills: z.array(z.string()).default([]),
Zero runtime cost — no helper, no wrapper, nothing changes about what the
schema validates. firstProse already skips every @-line, so the prose
summary and the machine-readable tag do not swallow each other.
It cannot resolve the way everything else does. resolve() requires the
name to be BOUND in the module, and the entire point of a string id is that
the target is not imported. So an @ref resolves by name, nearest first —
same module, then same directory, then graph-wide — and only when the
narrowing leaves exactly one. Two equally-near targets is an ambiguous tag,
not a winner: picking the first is the wggr failure one level down, an answer
that looks resolved and may be the wrong schema. That case is real here rather
than defensive — SkillDefinition is declared twice in one directory.
A tag naming nothing, and an ambiguous one, both land in refProblems and are
reported. Silence would make a typo indistinguishable from a field with no
tag — the same rule undetermined states.
id-ref is its own edge kind and is never folded into field, so a consumer
can tell a reference the source proves from one an author asserted. The
diagram draws it dashed for that reason.
What to tag is still a modelling question —
data-modelling. A tag is an assertion about the domain,
and a wrong one is worse than none: it draws an edge that does not exist.
The control worked in both directions
The same run found the hand-drawn diagram wrong: it draws
SkillDefinition *-- SkillSchemaRef : schemas, and SkillDefinitionSchema
has no schemas field at all. That corroborates bean 3lbz, which found
SkillDefinition.schemas had no readers and its only referencing code was a
script nothing invokes.
That is why the .puml is kept rather than retired. A hand-drawn model is
a cheap control for a generated one, and in one run it found a blind spot in
the reader and a stale relation in itself.
Adding or changing a schema — the loop
- Model first —
data-modelling. A field on the wrong entity is the error no schema check can catch. - Write the Zod schema in
.ts. The type isz.infer<typeof Schema>, never declared alongside it. - Tag the module —
@graphNode schema, or@graphNode none — <reason>with a reason.check:schema-nodesfails an untagged module, because an untagged one is silently absent from the published graph and absence is the one failure a consumer cannot tell apart from “this instance has none”. bun run cat schema:vizand look at your declaration in the viewer. Its kind, its fields, its generalisation, and what it references — and the three answers above.- Run
check:schema-nodes,typecheckandbun test.
schema:viz:check is worth running locally and is deliberately not a CI
gate (owner, 2026-09-20). The projection derives from the WHOLE repository, so
on a fast-moving repo the check reddens when somebody else merges rather than
when you forget — which is not an omission, and not what a gate is for. The
site build runs the writer at deploy, so nothing PUBLISHED goes stale; what can
lag is the committed copy, which exists to be browsable.
Keeping the projection’s gate honest
The projection is committed. It HAD a staleness gate, and the gate came out — because a staleness gate is only worth having if its red means an omission, and this one’s did not.
This one nearly failed that test. The projection carried each declaration’s line number, so any edit above a declaration made it stale: a merge that shifted two declarations by 36 lines went red with nothing else in 895 KB different. A red meaning “somebody added a blank line” teaches contributors to regenerate reflexively rather than to read the finding, which is the opposite of what a gate is for.
A line number is an editor coordinate, not a property of a declaration. It is kept on the reader and dropped from the projection. The general rule, worth applying to any generated artefact you gate:
If a red can be caused by a change that alters nothing the artefact describes, the artefact is carrying something it should not.
The same reasoning removed the per-module declaration list: a declaration already names its module, so the list was one fact written twice.
Where a viewer publishes — TWO rules, not three
Owner, 2026-09-20:
Paraphrased: two rules, not three. One is cat-harness handling the library/
directory, which holds a content instance’s assets; the other is that content
instance’s own handler, which mocks its current website.
| rule | form | what it is |
|---|---|---|
| 1 — a handler renders a kind’s assets | <base>/<handler>/<kind>/<optional subject> | cat-harness’s machinery over some graph. <base>/cat-harness/library/<iris-replica>/ |
| 2 — an instance presents itself | <base>/<instance>/ | the IRIS replica mocking the IRIS website |
The handler is the instance doing the rendering, the kind names what it renders, and the subject is optional — a page with no subject is the view over every subject.
Rule 1 is the canonical address; a bare <base>/<visualizer>/ is an opt-in alias
The owner, 2026-10-07 (bean t4xb): ”<base_url>/<visualizer> is an opt-in
(for prettiness or so), but <base_url>/<harness>/<visualizer> always
works.” So:
- Canonical: every visualiser is reachable at
<base>/<harness>/<visualizer>/, which is rule 1 with the handler as the harness. A generated link uses that form unless an alias is declared. - Alias:
<base>/<visualizer>/exists only where a declaration opts in. It must not collide with another alias, a harness route, or a reserved site-wide route (skills/,tools/,docs/,assets/, …), and a collision names both claimants.
Built 2026-10-09 (owner: “Need harness to declare visualizer is
renderedBy”): a visualiser is declared by its harness in visualisers, and
its alias is the opt-in. compose-docs.ts writes the alias as a redirect
for every page under the canonical route, and refuses one that would replace a
page; bun run cat check:visualiser-routes refuses, before any build, an
alias that names another alias, a harness, or anything the site already
publishes at its top level — naming both claimants. The reserved set is READ
from the site tree and the instance names rather than kept as a literal, so a
new site-wide route is reserved the day it exists. Link to the canonical form;
an alias is for a person typing a URL. The disk side of bean t4xb — a remote
mount landing at <name>/ — is still open, in remote-mount.
When the two collide — the handler wins
An instance that is both — this one renders every graph here AND has a site
of its own — makes rules 1 and 2 name the same path, <base>/<instance>/, and
nothing above chooses between them. The owner settled it on 2026-09-21:
harness handler wins.
So /cat-harness/ is the handler namespace and nothing else. The authored page
that sat there moved to docs/platform.md, and gen-handler-index.ts generates
the route’s index from harnessTiles. Bean 8h42.
The rule is not “the generated page wins” — it is that rule 1 is the one
with a kind in it, so it is the rule that can still address everything it
needs to once it owns the root. Rule 2 has nowhere else to go only if the
instance has no other name to present itself under, and an instance that is
doing both has exactly that: docs/platform.md is reachable, linked from the
generated index, and no route was lost.
This ruling was recorded on the generator and in the bean for four hours and
not here, beside the rules it arbitrates — which is
symbiotic-interaction §2’s third worked example
and the reason that section exists.
A subject page must NEVER be published at <base>/<subject>/<kind>/. That
is rule 2’s namespace, and a viewer parked there squats on the instance’s own
site. An earlier draft was about to publish the IRIS replica’s library/ exactly there,
on a misreading of “path to dir handled” as the page’s address rather than as
what the page shows.
viewerPlacement in gen-schema-viz.ts is the single implementation, shared
with the library viewer — two statements of one placement rule are two answers
the moment either moves. It also computes the page’s link back to its
projection from the page’s own depth, because a literal ../assets/… keeps
parsing and fetches nothing once a page gains a level.
One projection serves every page. A scoped page filters client-side on a
SCOPE constant; a second JSON per subject would be the same facts written
N+1 times, free to disagree the moment one is regenerated. Counts are scoped
too — reporting the graph-wide edge total on a page showing nine declarations
claims 512 edges among those nine.
docs/ is the same shape, and its subject means something specific
Owner, same day:
Paraphrased: <base>/cat-harness/docs/ is where all harness user
documentation is, so documentation at <base>/cat-harness/docs/<instance>/
is documentation ABOUT that instance, how it is ingested and so on — not
the instance’s content, whose source is the instance’s own docs/. Part of
the cat-harness docs/ handler’s job is to look out for docs/ directories
in harness kinds.
So a subject page under a handler is about its subject, rendered by the
handler — never the subject’s own content relocated. The content stays in the
subject’s own docs/, and the handler’s job includes looking for those
directories.
Measured 2026-09-20 and not yet true: only cat-harness has a docs/
directory and only it declares one, with dependents: "skip" — which was
precisely what stopped a dependent from getting its own. Superseded
2026-09-30 (option A): docs is a perInstance kind, so every instance
inheriting it gets its own, and each existing <instance>/docs/ is a member
of the one declared subgraph. Nothing was declared
against an absent directory here, because a declared-but-absent directory is
the dh4f defect. Bean n0nf carries the same skip finding from the root’s
side.
The relationship diagram — what is defined here, and HOW it is linked
A collapsible panel at the top of the viewer draws the declarations the page is scoped to as UML class boxes wired by their edges, each edge labelled with the field it goes through. That label is the point. Owner, 2026-09-20:
so you can see Role is linked to Task (how) etc. / the relatioship diagram or so
“Role is linked to Skill” is half an answer; “via skills, many” is the
whole one. A picture that shows connection without naming the relation is a
prettier version of the list it replaced.
It is scoped, and refusing is an answer
Owner, same day: “not the WHOLE thing”, and “what’s defined in that KG
harness”. So the diagram draws the page’s scope narrowed by the module
filter, and above DIA_MAX (40) declarations it refuses and says how to
get a picture — pick a module. Drawing 812 labelled boxes is exactly the
hairball kg-viewer measured at 1111 nodes; that rule is
applied here rather than argued with.
One hop of context, because counting an edge is not showing it
A strict module filter cuts the edge most worth seeing.
RoleDefSchema --skills--> SkillDefinitionSchema spans two files, so
filtering to role-graph.ts drew Role with its most interesting relation
missing and reported the loss as a number.
So a declaration just outside the filter that a drawn one touches is drawn, faded and dashed, marked as context rather than as part of the set. Context-to-context edges are not drawn — that would be a graph the page is not about.
The fade is load-bearing. It shipped broken once: the CSS landed but the
class was never applied, so context boxes were indistinguishable from real
ones — a diagram that quietly widened its own scope. Caught by counting
.dia-ctx in the render test rather than by reading the diff.
Three edge kinds, and id-ref is drawn differently on purpose
| kind | drawn | means |
|---|---|---|
extends | solid, hollow triangle | generalisation |
field | solid, open arrow | a reference in the source |
id-ref | dashed | an association declared with @ref over a plain string |
A field reference is something the reader found. An id-ref is something an
author asserted, because the target is a string id the reader structurally
cannot see. Drawing them alike would claim the reader saw something it did
not — the same honesty the three undetermined/unresolved/external states
already enforce one level down.
Layout
Longest-path layering so a generalisation reads downward, then one barycentre pass to reduce crossings, ties broken by name. The walk is depth-capped rather than assuming a DAG: mutually recursive types are real here, and a cycle must settle rather than hang.
Static, in the strict sense — computed once, no simulation, nothing to settle.
Dragging and alternate arrangements are bean whbf.
Edge labels stagger by index, because two edges whose midpoints land in the
same band overplot: permissions * 0..1 and actors * 0..1 came out on top of
each other. The stagger only has to separate neighbours, not be optimal.
Clicking a box opens its definition
Which is what makes the diagram navigation rather than a poster — the owner’s original ask was “browsable, so i can give overview like browsing”.
Orphan pages — a generator that writes must also prune
A --check twin catches a page that is wrong. It cannot catch one that
should not exist, and the difference is structural: --check inspects the
files it is about to WRITE, so a file the generator no longer writes is outside
what it looks at.
Measured, bean ankg: #604 renamed folio-assist-sci/ to
the science layer. Subject slugs derive from the entry’s path, so
regeneration correctly wrote the new page — and left the old one standing,
serving a subject no declaration describes, at a URL nothing links to. It was
removed by hand. That is the yl5w shape pointed the other way: there a claim
resolved to no file, here a file answers to no claim.
Ownership comes from the FILE, never from the directory
This is the load-bearing rule, and it is
deletion-requires-confirmation applied
to the tool most tempted to break it. “Everything under this directory is
mine” is an assumption; “this file says it was generated by me” is a check.
orphanSubjectPages in gen-schema-viz.ts — shared by gen-library-viz.ts
and gen-auto-docs.ts — makes that check the narrowest one available: a
directory is prunable only when its index.html declares itself the subject
page for that very directory, var SCOPE = "<dirname>";. A page naming a
different subject is a page this generator did not write for this location,
so it is foreign. So is a directory with no index.html. The directory
scopes the SEARCH; the file decides the DELETE, and everything else is
reported and left.
Prefer the self-naming test to a “generated by me” marker where one is
available. A marker answers did I write this; naming the directory answers
did I write this HERE, which is strictly stronger and needs no new bytes in
the page. Reach for a marker only when the pages carry no such identity — the
state-visualizer case, where dashboards publish at the site root among
directories nothing here owns.
Do not write another one
There are already four selectors for one question — prunableStickies
(ensure-landing-sticky.ts), OWNED (the IRIS replica’s scripts/gen-iris-pages.ts,
#607), orphanSubjectPages, and prunableDashboards (state-visualizer.ts).
Each is correct; the multiplicity is the defect, tracked as bean s8nu. A new
generator imports orphanSubjectPages rather than minting a fifth.
The ratchet lives in scripts/tests/viewer-orphans.test.ts and asserts against
the committed tree rather than a fixture — every published subject page
declares itself — because a fixture passes on the day somebody re-keys a real
subject.
Editing a viewer — no backticks
Both viewers are a whole HTML document inside one TypeScript template literal. A backtick anywhere inside it — including in a JavaScript comment in the embedded script — terminates the string, and the failure surfaces as a parse error a couple of hundred lines from the mistake. It has happened twice.
schemas/viz-generators.test.ts imports both generators, so a stray backtick
reddens the suite rather than only the next person’s generator run. Run it
before assuming a generator change is fine.
Where the boundaries are
schemas/holds content.BASE_GRAPH_TYPOLOGIES.schemascarriesholds: "content"— a shape is the subject matter of the schema graph, and it is true before anything is validated against it. Seecontent-context-and-state-graphs.- Adding a graph TYPOLOGY is a different job —
directory-conventions§”Adding a graph typology”, and decide which layer owns it first: if it renders, it is not the harness’s. - Publishing a schema at a dereferenceable
$idisharness-schema-export, a separate rendering with a separate consumer. Generate as many renderings as have a consumer, and no more. - An unwired generator is worse than none. Three schema generators were
written here before the current one and all three rotted, because nothing
ever asked whether their output still matched their input. If you add a
fourth rendering, it ships with a
--checktwin in the gate set or it does not ship.