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_id | label | |
|---|---|---|
| Shape | A random 10-character GUID | Bug-27, Task-15, Git-Repo-1 |
| Generated from | Nothing — it is random | The node's type and its per-type index |
| Stable under | Everything. It never changes | Its own type's counter |
| Used by | Storage, links, the file path on disk | Humans, 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 |
| Verb | Inverse | Verb | Inverse |
|---|---|---|---|
blocks | blocked-by | contains | contained-by |
has-task | task-of | has-project | project-of |
assigned-to | assignee-of | has-phase | phase-of |
depends-on | dependency-of | has-feature | feature-of |
asks | asked-by | and one more — below | |
The tenth pair, and the tension it creates
The tenth shipped pair is relates-to/relates-to — a self-inverse. And the wider corpus explicitly forbids it:
“You can never haverelates-to, becauserelates-tois 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.py — the 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.
| Primitive | Regex | Max | Example |
|---|---|---|---|
Safe_Str__Node_Type | ^[a-z][a-z0-9-]*$ | 50 | bug, git-repo |
Safe_Str__Node_Label | ^[A-Z][a-zA-Z]*(-[A-Z][a-zA-Z]*)*-\d{1,5}$ | 100 | Bug-27, Git-Repo-1, User-Story-1 |
Safe_Str__Status | ^[a-z][a-z0-9-]*$ | 50 | in-progress |
Safe_Str__Link_Verb | ^[a-z][a-z0-9-]*$ | 50 | blocks, has-task |
Safe_Str__Node_Type_Display | ^[A-Z][a-zA-Z0-9 ]*$ | 100 | Bug, 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:
- “refactor all these classes to use 'Issue'” — the vocabulary is unsettled between node and issue, and both appear in the codebase.
- “this should be a
Type_Safecollection and we shouldn't be using raw primitives likestr,Any” — which is aboutproperties: Dict[str, Any], the one field in the schema with no type safety at all. A 2,734-word Type-Safe Properties brief was written to fix it and the fix was never built;Enum__Property__Typehas zero references anywhere.
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.