issues-fs.sgit.ai / model

Nodes, edges and labels

Prose descriptions of Issues-FS disagree with each other. The schema files do not. This page is built from them — Schema__Node, Schema__Node__Link, Safe_Str__Graph_Types and the storage enum — so that a reader can check every claim against a file rather than against another document.

The node

issues_fs/schemas/graph/Schema__Node.py — the base structure for every entity, whatever its type.

class Schema__Node(Type_Safe):
    node_id       : Node_Id                          # random 10-char GUID, for machines
    node_type     : Safe_Str__Node_Type              # bug, task, feature, person…
    node_index    : Safe_UInt                        # per-type sequential number
    label         : Safe_Str__Node_Label             # human-readable: "Bug-27", "Task-15"
    title         : Safe_Str__Text
    description   : Safe_Str__Issue__Node__Description
    status        : Safe_Str__Status                 # type-specific
    created_at    : Timestamp_Now
    updated_at    : Timestamp_Now
    created_by    : Obj_Id                           # person/agent node_id
    tags          : List[Safe_Str__Text]
    links         : List[Schema__Node__Link]         # relationships
    properties    : Dict[str, Any]                   # type-specific

Thirteen fields. In the flagship live graph, all thirteen are present on all 71 nodes — the schema is not aspirational.

Two identities per node, and why

Every node carries a machine identity and a human one, and the split is deliberate rather than redundant.

node_idlabel
ShapeA random 10-character GUIDBug-27, Task-15, Git-Repo-1
Generated fromNothing — it is randomThe node's type and its per-type index
Stable underEverything. It never changesIts own type's counter
Used byStorage, links, the file path on diskHumans, agents, and every CLI argument

This is why issues-fs show Bug-27 works while the file on disk is addressed by GUID. A label is a lookup key for people; an ID is a reference for machines. Merging them would force a choice between labels that renumber on a merge and identifiers nobody can type.

The edge

issues_fs/schemas/graph/Schema__Node__Link.py — and the comment on the class is the design in one line: “Links are bidirectional and denormalized (stored on both ends).”

class Schema__Node__Link(Type_Safe):
    link_type_id  : Obj_Id
    verb          : Safe_Str__Link_Verb              # "blocks", "has-task", "assigned-to"
    target_id     : Obj_Id
    target_label  : Safe_Str__Node_Label             # denormalized, for display
    created_at    : Timestamp_Now

Every edge is written twice

One issues-fs link Task-1 blocks Bug-1 writes to two files: blocks on Task-1, and the declared inverse blocked-by on Bug-1. There is no edge table to consult and no join to perform — whichever node you load, its relationships arrive with it.

This is why the numbers look wrong if you count naively. The flagship live graph reports 141 link entries across 71 nodes, for roughly 70 logical relationships. Nothing is duplicated by accident: each relationship is stored once per endpoint, by design. A reader who counts entries and expects relationships will be off by a factor of two, so the site states the two numbers separately everywhere.

The cost of the design is the usual cost of denormalisation — target_label is a copy, and a copy can go stale. The benefit is that reading a node's neighbourhood is one file read.

Types are first-class, and constrained

A node type is not a string in a list. It carries display metadata, an icon and colour, and — the part that matters — its own set of legal statuses.

Schema__Node__Type:   type_id · name · display_name · description · icon
                      color (Safe_Str__Hex_Color) · statuses[] · default_status
                      properties[] (Schema__Property__Definition)

Schema__Link__Type:   link_type_id · verb · inverse_verb · description
                      source_types[] · target_types[]

Those last two fields are the thing worth stopping on. source_types and target_types are domain and range constraints on an edge type: has-phase is not a free-form string, it is a verb that may join a project to a phase and has phase-of as its inverse. That is what makes this a typed property graph rather than a folder of JSON with a links array.

The 12 node types and 10 link types as shipped

issues-fs types init writes these. They are also what the flagship live graph runs on.

Node types (12)
git-repo · bug · task · feature · person · project · phase · research · spike · security-review · threat-model · question
VerbInverseVerbInverse
blocksblocked-bycontainscontained-by
has-tasktask-ofhas-projectproject-of
assigned-toassignee-ofhas-phasephase-of
depends-ondependency-ofhas-featurefeature-of
asksasked-byand one more — below

The tenth pair, and the tension it creates

The tenth shipped pair is relates-to/relates-toa self-inverse. And the wider corpus explicitly forbids it:

“You can never have relates-to, because relates-to is meaningless — two things always relate to each other.”

It ships in the default link-types.json anyway, and exactly one edge instance in the live graph uses it. The tension is narrated rather than hidden, because it is a real and instructive one: a self-inverse verb passes every structural check the type system makes — it has a verb, an inverse, and endpoint constraints — while carrying no information at all. Type systems catch shape, not meaning. graphs.sgit.ai makes the argument at length; it is open question Q4 here.

The five primitives

issues_fs/schemas/graph/Safe_Str__Graph_Types.pythe best single teaching artefact in the codebase. Five regex-validated string types, all in MATCH mode with strict_validation = True, which means invalid input raises rather than being quietly coerced.

PrimitiveRegexMaxExample
Safe_Str__Node_Type^[a-z][a-z0-9-]*$50bug, git-repo
Safe_Str__Node_Label^[A-Z][a-zA-Z]*(-[A-Z][a-zA-Z]*)*-\d{1,5}$100Bug-27, Git-Repo-1, User-Story-1
Safe_Str__Status^[a-z][a-z0-9-]*$50in-progress
Safe_Str__Link_Verb^[a-z][a-z0-9-]*$50blocks, has-task
Safe_Str__Node_Type_Display^[A-Z][a-zA-Z0-9 ]*$100Bug, User Story

Plus Safe_Str__Hex_Color (^#[0-9A-Fa-f]{6}$), Safe_Str__Issue_Id (^[Ii]ssue-\d{1,5}$), and Safe_Str__Issue__Node__Description — max 50,000 characters, REPLACE mode, preserving \n, \r and \t, and allowing empty.

Read the label regex against the label generator and the whole scheme falls out:

display_type = node_type.capitalize()                        # "bug" → "Bug"
return Safe_Str__Node_Label(f"{display_type}-{node_index}")  # "Bug-27"

A type is lower-kebab; a label is that type title-cased, joined to a per-type integer. git-repo becomes Git-Repo-1, which is why the label regex allows multiple hyphenated capitalised segments before the number.

Storage backends

issues_fs/schemas/enums/Enum__Graph__Storage__Backend.py

MEMORY     = "memory"        # in-memory (tests)
LOCAL_DISK = "local_disk"    # local file system
SQLITE     = "sqlite"        # SQLite database
ZIP        = "zip"           # ZIP archive

Four, all real, all constructed through the same factory over the memory-fs abstraction:

repo = Graph__Repository__Factory.create_local_disk(root_path=".issues")
repo = Graph__Repository__Factory.create_sqlite(db_path="issues.db")
repo = Graph__Repository__Factory.create_memory()
repo = Graph__Repository__Factory.create_zip(zip_path="graph.zip")

They are classmethods. A README example shows Graph__Repository__Factory(root_path=…).create_repository(); there is no such constructor and no such method — one of the corrections. A cloud object-store provider is claimed in four documents and is not in the enum.

What the schema files admit about themselves

Schema__Node.py carries five # todo: markers, and two of them are load-bearing:

Similarly issues_fs/schemas/identifiers/Issue_Id.py carries the comment “we need to refactor the code to use this” — it exists and Schema__Node does not use it. These are worth surfacing rather than tidying away: the honest state of a codebase includes what its authors have already noticed.

For an agent

A node is a JSON object with 13 fields, addressed on disk by a 10-character GUID (node_id) and addressed by everything else by a human label (label, e.g. Bug-27) built as node_type.capitalize() + - + per-type index. Always use the label in commands; never construct a GUID. Edges are stored on both endpoints — writing blocks on the source implies blocked-by on the target, so never create the reverse yourself and expect roughly 2× link entries as logical relationships when counting. Link types carry source_types/target_types constraints; check them before linking. Node types, statuses and verbs are lower-kebab and validated by regex in strict mode — an invalid value raises rather than being coerced. Storage backends are memory, local_disk, sqlite, zip, constructed via Graph__Repository__Factory classmethods.