Documentation
¶
Overview ¶
Package framework is the Hanzo Framework: a metadata-driven DocType engine, native Go on Base/SQLite, mounted in the unified cloud binary at /v1/framework/*. It is the rebuilt-in-Go successor to Frappe's DocType/metadata core — the FOUNDATION on which CMS content-types, ERPNext DocTypes, and Helpdesk become "just DocTypes", so ONE engine + ONE generic UI renders every business app (maximal DRY). There is NO Frappe/Python runtime dependency; the engine itself is pure Go.
The model (faithful to Frappe, flattened onto SQLite) ¶
- A DocType is a metadata definition: {name, module, fields[], permissions[], isSingle, isSubmittable, autoname, titleField}. It is data, stored per-org.
- A DocField mirrors Frappe's DocField: {fieldname, fieldtype, label, reqd, options, default, ...}. The fieldtype set is Data, Int, Float, Currency, Check, Date, Datetime, Text, SmallText, LongText, Select, Link, Table, Attach, JSON, Password.
- A Document is a schemaless record validated against its DocType at write and persisted as a JSON blob keyed by (org, doctype, name). docstatus is the Frappe lifecycle: 0=draft, 1=submitted, 2=cancelled.
Tenant isolation (the security boundary) ¶
Every request resolves its org through the ONE boundary — clients/principal .Tenant — which returns the org ONLY for a VALIDATED principal (a gateway- or BFF-minted X-User-Id from a verified IAM credential), verbatim and cloned. A forged X-Org-Id with no validated principal is refused 403 before any store access. Every doctype, document, series counter, and role row carries an `org` column and every query filters WHERE org=?, so org A's schema and data are physically invisible to org B. There is no second org derivation path anywhere in this package.
Surface (all org-scoped; /v1 only) ¶
GET /v1/framework/doctypes DocType registry list -> {data:[…]}
POST /v1/framework/doctypes define a DocType -> DocType (201)
GET /v1/framework/doctypes/:name DocType definition -> DocType
PUT /v1/framework/doctypes/:name replace a DocType -> DocType
DELETE /v1/framework/doctypes/:name delete a DocType (+ its docs)
GET /v1/framework/roles per-org role assignments -> {data:[…]}
POST /v1/framework/roles assign (user,role) -> Role (201)
DELETE /v1/framework/roles/:user/:role revoke (user,role)
GET /v1/framework/:doctype list documents -> {data:[…]}
?filters=&fields=&limit=&order_by=
POST /v1/framework/:doctype create a document -> Document (201)
GET /v1/framework/:doctype/:name document detail -> Document
PUT /v1/framework/:doctype/:name update a document -> Document
DELETE /v1/framework/:doctype/:name delete a document
POST /v1/framework/:doctype/:name/submit docstatus 0→1 (submittable) -> Document
POST /v1/framework/:doctype/:name/cancel docstatus 1→2 (submittable) -> Document
serve.go auto-registers GET /v1/framework/health. Order 129 binds the surface before the AI subsystem's /v1/* catch-all (150); the static /doctypes + /roles routes register before the generic /:doctype routes so Fiber's first-match scan resolves them unambiguously (and those keywords are reserved DocType names).
Index ¶
- Constants
- Variables
- func AlwaysOnModules() []string
- func Delete(ctx context.Context, org, doctype, name string) error
- func FindByField(ctx context.Context, org, doctype, field, value string) (string, error)
- func Installed(ctx context.Context, org, doctype string) bool
- func IsValidationError(err error) bool
- func MarkAlwaysOn(module string)
- func ModuleInstalled(ctx context.Context, org, module string) bool
- func Mount(app cloud.Router, deps cloud.Deps) error
- func RegisterHook(doctype, action string, fn Hook)
- func RegisterModule(module string, fixtures []DocType)
- func RegisteredHookCount() int
- func RegisteredModules() []string
- func Shutdown() error
- func UpdateData(ctx context.Context, org, doctype, name string, data map[string]any) error
- type DocField
- type DocPerm
- type DocType
- type Document
- type Event
- type Hook
- type Ingested
- type Lease
- type ListOpts
- type Role
- type Store
- func (s *Store) AssignRole(ctx context.Context, org, user, role string) error
- func (s *Store) Close() error
- func (s *Store) CountDocuments(ctx context.Context, org, doctype string) (int, error)
- func (s *Store) CreateDocType(ctx context.Context, org string, dt DocType) (DocType, error)
- func (s *Store) CreateDocument(ctx context.Context, org string, dt *DocType, data map[string]any, ...) (Document, error)
- func (s *Store) DeleteDocType(ctx context.Context, org, name string) (bool, error)
- func (s *Store) DeleteDocument(ctx context.Context, org, doctype, name string) (bool, error)
- func (s *Store) GetDocType(ctx context.Context, org, name string) (DocType, error)
- func (s *Store) GetDocument(ctx context.Context, org, doctype, name string) (Document, error)
- func (s *Store) ListDocTypes(ctx context.Context, org string) ([]DocType, error)
- func (s *Store) ListDocuments(ctx context.Context, org, doctype string, opts ListOpts) ([]Document, error)
- func (s *Store) ListRoles(ctx context.Context, org string) ([]Role, error)
- func (s *Store) ReplaceDocType(ctx context.Context, org string, dt DocType) (DocType, error)
- func (s *Store) RevokeRole(ctx context.Context, org, user, role string) (bool, error)
- func (s *Store) RolesFor(ctx context.Context, org, user string) ([]string, error)
- func (s *Store) SeedOwnerIfUnowned(ctx context.Context, org, user string) (bool, error)
- func (s *Store) SetDocStatus(ctx context.Context, org, doctype, name string, from, to int) (Document, error)
- func (s *Store) UpdateDocument(ctx context.Context, org string, dt *DocType, name string, data map[string]any) (Document, error)
- func (s *Store) UpsertSingle(ctx context.Context, org string, dt *DocType, data map[string]any) (Document, error)
Constants ¶
const ( FieldData = "Data" FieldInt = "Int" FieldFloat = "Float" FieldCurrency = "Currency" FieldCheck = "Check" FieldDate = "Date" FieldDatetime = "Datetime" FieldText = "Text" FieldSmall = "SmallText" FieldLong = "LongText" FieldRichText = "RichText" // WYSIWYG body; value is a Lexical EditorState JSON string FieldSelect = "Select" FieldLink = "Link" FieldTable = "Table" FieldAttach = "Attach" FieldJSON = "JSON" FieldPassword = "Password" )
Fieldtype constants mirror Frappe's DocField.fieldtype. This is the closed set the engine validates; a DocField with any other fieldtype is rejected at define time (fail closed — an unknown type has no validation and is a hole).
const ( ActionBeforeInsert = "before_insert" ActionBeforeSave = "before_save" // runs on BOTH create and update ActionAfterSave = "after_save" ActionOnSubmit = "on_submit" ActionOnCancel = "on_cancel" ActionOnTrash = "on_trash" // before delete )
Hooks — the DocType lifecycle extension contract.
A sibling app lane (CMS, ERP, CRM, Helpdesk) attaches server-side behavior to a DocType by registering a Go Hook against a (doctype, action) pair at process init. This is the pure-Go path, live now. A gpython/goja SCRIPT runner is a LATER, orthogonal add that registers a Hook closure implementing THIS SAME interface — the engine never grows a second hook path. That is the whole point of defining the interface now: one seam, many implementations.
Trust & scope
A Hook is trusted FIRST-PARTY Go compiled into the binary — like the engine's own logic, inside the trust boundary. It is keyed by doctype NAME and applied per-org: the Event carries the VALIDATED org (never a client header), and any sibling data a hook reads/writes through ev.Store MUST be scoped with ev.Org, so a hook stays in its tenant's lane. (When the script runner lands, untrusted scripts run sandboxed and org-pinned — that isolation is designed there, not here.)
When hooks run (deadlock-safe, gate-style)
Hooks run OUTSIDE the store's write transaction. The store uses a single SQLite connection (MaxOpenConns(1)); running a hook that touches ev.Store inside an open transaction would deadlock on that one connection. So the service orchestrates phases and the store performs each state change as its own atomic statement:
create: validate → BeforeInsert → BeforeSave → INSERT → AfterSave update: validate → BeforeSave → UPDATE → AfterSave submit: load(0) → OnSubmit(gate) → docstatus 0→1 cancel: load(1) → OnCancel(gate) → docstatus 1→2 delete: load → OnTrash(gate) → DELETE
A gate hook (BeforeInsert/BeforeSave/OnSubmit/OnCancel/OnTrash) that returns a non-nil error ABORTS the operation BEFORE the state change — nothing is written (mapped to HTTP 422). BeforeInsert/BeforeSave may MUTATE ev.Doc.Data (e.g. compute a total); the mutated document is what gets persisted. AfterSave runs after the row is written; its error is surfaced but the write already landed.
const ( // RoleSystemManager is the admin role: it manages DocTypes + role assignments // and is granted every document right. Mirrors Frappe's System Manager. RoleSystemManager = "System Manager" // RoleAll is the implicit role every validated org member holds (Frappe "All"). RoleAll = "All" )
Permissions — per-org, DocType perms by role, enforced on every operation.
The role source. IAM's JWT carries no per-user role set into the cloud binary (SanitizeIdentity restores only user/email/org/isAdmin), so the framework owns its role model per-org, exactly as Frappe records "Has Role" within a site: the fw_roles table maps (org, user) → roles, managed at /v1/framework/roles.
The gate. Every handler resolves an `access` through ONE seam (resolveAccess), which begins with the ONE tenant derivation (principal.Org) and never a second path. From the validated principal it derives the caller's effective roles and whether they are a manager, then answers can(doctype, right).
Variables ¶
var ( ErrNotFound = errNotFound ErrConflict = errConflict ErrBadRef = errBadRef )
Exported error sentinels let an in-process caller (e.g. the content lane) classify the errors Ingest/Get/UpdateData/Search return WITHOUT string-matching. They alias the package's internal sentinels — the ONE definition stays in store.go, these are just the public handles.
Functions ¶
func AlwaysOnModules ¶ added in v1.801.191
func AlwaysOnModules() []string
AlwaysOnModules returns the sorted set of modules marked always-on — introspection for a catalog / link-guard test.
func Delete ¶ added in v1.800.1
Delete removes a document in-process — the twin of the HTTP DELETE: it loads the document, runs the on_trash gate hooks (a returned error aborts the delete), then removes the row. `org` MUST be a validated tenant the caller already resolved. A first-party subsystem (the KB lane reconciling wikilink edges when a page is re-saved or trashed) uses it so edge cleanup runs the SAME lifecycle path as any other delete — never a forked write path. It returns ErrNotFound when the document does not exist (idempotent from the caller's view: a missing edge is a no-op).
func FindByField ¶ added in v1.786.72
FindByField returns the name of the first document in (org, doctype) whose `field` equals `value`, or "" if none. A connector uses it to find an existing kb-source by external_id (idempotent re-sync: update in place vs. create new). `field` is validated against the doctype schema by ListDocuments' bound json path.
func Installed ¶ added in v1.786.72
Installed reports whether `doctype` exists in `org` (i.e. the module was installed). A connector checks this before a sync so it can return an honest "install the kb module first" rather than a doctype-not-found error mid-sync.
func IsValidationError ¶ added in v1.786.216
IsValidationError reports whether err is a document-schema violation (the 400-class error validateDoc returns), so an in-process caller maps it to Bad Request rather than a 500 — the same *validationError the HTTP layer maps via mapErr.
func MarkAlwaysOn ¶ added in v1.801.191
func MarkAlwaysOn(module string)
MarkAlwaysOn marks a registered module as ALWAYS-ON: its DocType fixtures resolve for EVERY org without a per-org install — GetDocType, ListDocTypes, and the Installed predicate all satisfy them for an org that never ran the install step. A standard lane (the Guide's marketing content model) calls this from its init() right after RegisterModule, the same self-declaration pattern, so a fresh org's journey works with no manual setup.
SCOPE — it changes only SCHEMA availability, NEVER tenant data isolation. Every document row stays physically org-scoped (the `org` column on every query); a resolved always-on DocType only says "this schema exists for you", it exposes no other org's records. A per-org stored DocType (a customization) always overrides the fixture (see GetDocType). See always_on_isolation_test.go for the cross-tenant proof.
func ModuleInstalled ¶ added in v1.801.218
ModuleInstalled reports whether the content model of `module` resolves for `org` — the org installed the lane (or the lane is always-on). It is the module-granularity sibling of Installed: an observe/growth reader asks "does this org run cms/erp?" without importing the framework store. A module is present when ANY of its registered DocType fixtures resolves for the org (GetDocType satisfies an always-on fixture for every org and an opt-in fixture only where the org ran the install). Org-scoped (each GetDocType keys on `org`) and nil-safe: an unmounted framework, an unknown module, or a store miss all yield false — never a spurious true and never any org's data (only the boolean).
func RegisterHook ¶
RegisterHook attaches fn to (doctype, action). Multiple hooks for the same key run in registration order; the first error aborts the operation. Register from a package init() so the wiring is declared once at build time. This is the ONE way a DocType gains server-side behavior.
func RegisterModule ¶
RegisterModule declares the DocType fixtures a module installs. Call from a package init() so the content model is declared once at build time. The module name is stamped onto every fixture at install so a lane's DocTypes are always discoverable by `module`. Fixtures are cloned on registration so a caller's slice can never mutate the registry.
func RegisteredHookCount ¶ added in v1.786.216
func RegisteredHookCount() int
RegisteredHookCount returns the number of (doctype, action) keys carrying at least one registered hook. Exported so the composition root's link-guard test can assert an app lane's lifecycle hooks — notably erp's ledger-posting hooks — are compiled into the binary. Hooks register from a package init() SEPARATELY from module DocTypes; asserting the module alone would stay green if a future refactor split registerHooks() into its own file whose blank import got dropped.
func RegisteredModules ¶ added in v1.786.216
func RegisteredModules() []string
RegisteredModules returns the sorted set of registered content-module names. Exported so the composition root's link-guard test can assert the app lanes (cms/erp/help) are compiled into the binary — their fixtures and hooks register from a package init(), so a missing blank import silently empties this set.
func UpdateData ¶ added in v1.786.72
UpdateData replaces the data of an existing document (draft only), running the before_save + after_save hooks — the in-process twin of the HTTP PUT. A connector uses it to refresh an already-ingested kb-source (same external_id) on re-sync so the vector point is updated in place rather than duplicated. `name` is the engine-assigned document name from a prior Ingest.
Types ¶
type DocField ¶
type DocField struct {
Fieldname string `json:"fieldname"`
Fieldtype string `json:"fieldtype"`
Label string `json:"label,omitempty"`
Reqd bool `json:"reqd,omitempty"`
// Options is fieldtype-dependent: Select → newline-separated choices; Link →
// target DocType name; Table → child DocType name. Empty otherwise.
Options string `json:"options,omitempty"`
// Default is applied when a create omits the field (before validation).
Default string `json:"default,omitempty"`
Unique bool `json:"unique,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
Hidden bool `json:"hidden,omitempty"`
// InListView flags the field for the generic list UI (metadata only).
InListView bool `json:"inListView,omitempty"`
// FetchFrom auto-populates this field from a linked document, in the form
// "link_fieldname.source_fieldname": on save the engine loads the doc named by
// the Link field `link_fieldname` and copies its `source_fieldname` here.
FetchFrom string `json:"fetchFrom,omitempty"`
}
DocField is one field in a DocType, faithful to Frappe's DocField. JSON tags are the wire contract the sibling app lanes (CMS/ERP/CRM) and the generic @hanzo/ui DocType renderer build to.
type DocPerm ¶
type DocPerm struct {
Role string `json:"role"`
Read bool `json:"read,omitempty"`
Write bool `json:"write,omitempty"`
Create bool `json:"create,omitempty"`
Delete bool `json:"delete,omitempty"`
Submit bool `json:"submit,omitempty"`
Cancel bool `json:"cancel,omitempty"`
}
DocPerm is a role's rights on a DocType, faithful to Frappe's DocPerm. The engine enforces these per-org against the caller's resolved roles.
type DocType ¶
type DocType struct {
Name string `json:"name"`
Module string `json:"module,omitempty"`
IsSingle bool `json:"isSingle,omitempty"`
IsSubmittable bool `json:"isSubmittable,omitempty"`
// Autoname is the naming rule (see naming.go): "" or "hash" → random id;
// "field:fieldname" → value of that field; "prompt" → client supplies name;
// any other value is a series pattern, e.g. "INV-.YYYY.-.#####".
Autoname string `json:"autoname,omitempty"`
TitleField string `json:"titleField,omitempty"`
Fields []DocField `json:"fields"`
Perms []DocPerm `json:"permissions,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
UpdatedAt int64 `json:"updatedAt,omitempty"`
}
DocType is a metadata definition. It is per-org data: the same DocType `name` may exist independently in many orgs with different fields, and one org's definition is invisible to another.
type Document ¶
type Document struct {
Name string
DocType string
DocStatus int
Data map[string]any
CreatedAt int64
UpdatedAt int64
}
Document is a schemaless record: Data holds the validated field values, keyed by fieldname. The HTTP layer serializes it via wireDoc (Data + envelope keys), so this internal shape is the ONE representation the store works with.
func Get ¶ added in v1.786.216
Get returns a single document by name in (org, doctype) — the read-one twin of Search for a first-party in-process reader (the content lane reads the current document to compute a lifecycle transition). `org` MUST be a validated tenant the caller already resolved. It returns ErrNotFound when the document does not exist, so the caller can answer 404 rather than 500.
func Search ¶ added in v1.786.72
func Search(ctx context.Context, org, doctype string, filters map[string]string, limit int) ([]Document, error)
Search is the in-process, org-scoped document list a first-party subsystem (the KB retrieval surface) uses to hydrate search hits or count ingested docs without re-implementing the store query. It is a thin, validated pass-through to ListDocuments — every result is physically scoped to `org`.
type Event ¶
type Event struct {
// Action is one of the Action* constants.
Action string
// Org is the VALIDATED tenant (clients/principal.Org). A hook that touches
// sibling data MUST scope every query by it.
Org string
// DocType is the document's DocType name.
DocType string
// Doc is the document being acted on. In BeforeInsert/BeforeSave a hook may
// mutate Doc.Data and the mutation is persisted; elsewhere treat it as read.
Doc *Document
// Prev is the previous persisted state on update/submit/cancel; nil on insert.
Prev *Document
// Meta is the document's DocType definition (fields, perms, flags).
Meta *DocType
// Store is in-org data access for hooks that read or write sibling documents.
Store *Store
// Logger is the framework subsystem logger.
Logger luxlog.Logger
}
Event is the value that flows through a lifecycle Hook.
type Hook ¶
Hook is a server-side lifecycle handler. Returning a non-nil error from a gate phase aborts the operation (HTTP 422) before any state change.
type Ingested ¶ added in v1.786.72
Ingested is the minimal result of an in-process create: the org, doctype, and the engine-assigned document name (a connector records this to key incremental re-syncs).
func Ingest ¶ added in v1.786.72
func Ingest(ctx context.Context, org, doctype string, data map[string]any, requestedName string) (Ingested, error)
Ingest creates a document of `doctype` in `org` from already-trusted field data, running the full validate + lifecycle-hook pipeline (so the after_save indexing hook fires exactly as it does for an HTTP create). `org` MUST be a validated tenant the caller already resolved — Ingest does not derive it. `requestedName` is used only for prompt/single naming (empty for the common autoname/hash case).
It errors ("framework: not mounted") if called before the subsystem is mounted, and surfaces validation/lifecycle/store errors verbatim so a connector can record them on its connection status.
type Lease ¶ added in v1.786.216
type Lease struct {
// contains filtered or unexported fields
}
Lease is an acquired exclusive claim on (org, key). Release it when the guarded critical section completes; the TTL is only the crash safety net.
func AcquireLease ¶ added in v1.786.216
func AcquireLease(ctx context.Context, org, key string, ttl, wait time.Duration) (*Lease, bool, error)
AcquireLease takes an exclusive lease on (org, key) for up to `ttl`, polling with bounded backoff until it wins or `wait` elapses. It returns:
- (lease, true, nil) — acquired; the caller owns the critical section and MUST Release when done (a defer is idiomatic);
- (nil, false, nil) — a LIVE lease stayed held by someone else for the whole `wait` window; the caller answers an honest "in progress"/"retry", never a 5xx;
- (nil, false, err) — a genuine store failure (or ctx cancellation).
`ttl` MUST exceed the guarded section so a live holder is never pre-empted mid-flight.
func (*Lease) Release ¶ added in v1.786.216
Release frees the lease IFF this holder still owns it. Best-effort and idempotent: a lease already reclaimed by TTL is a no-op (holder-scoped delete), and a store error is returned for the caller to log — never to fail the already-completed work. Release with a detached context so a cancelled request still frees the row promptly (rather than leaving it to TTL).
type ListOpts ¶
type ListOpts struct {
Filters map[string]string
OrderField string // "" → updated_at
Desc bool
Limit int
}
ListOpts is a parsed, validated list query. Filters keys are declared field names (or the special "name"/"docstatus"); OrderField is a resolved sort key.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the framework database. ONE SQLite file ({DataDir}/framework.db) holds every org's DocTypes, documents, naming counters, and role assignments; tenant isolation is the `org` column, present on EVERY table and EVERY query. This is the ONE storage pattern (mirrors clients/crm, clients/cms). MaxOpenConns(1) serializes writes against the single-writer SQLite file.
func (*Store) AssignRole ¶
func (*Store) CountDocuments ¶
CountDocuments returns the org's document count for a doctype (real, per-org).
func (*Store) CreateDocType ¶
func (*Store) CreateDocument ¶
func (s *Store) CreateDocument(ctx context.Context, org string, dt *DocType, data map[string]any, requestedName string) (Document, error)
CreateDocument names and inserts a validated document at docstatus 0. `data` is the canonical, already-validated field map (the service calls validateDoc first). Naming follows dt.Autoname; series naming increments a per-org counter inside the same transaction so concurrent creates never collide.
func (*Store) DeleteDocType ¶
DeleteDocType removes a DocType and ALL of its documents in one transaction — there are no orphaned documents without a schema. Returns false if absent.
func (*Store) DeleteDocument ¶
DeleteDocument removes a document by key. Returns false if absent. The service enforces the lifecycle guard (a submitted doc must be cancelled first).
func (*Store) GetDocType ¶
func (*Store) GetDocument ¶
func (*Store) ListDocTypes ¶
func (*Store) ListDocuments ¶
func (s *Store) ListDocuments(ctx context.Context, org, doctype string, opts ListOpts) ([]Document, error)
ListDocuments returns the org's documents of a doctype, applying equality filters (via json_extract, all values BOUND), an order key, and a bounded limit. Every value is a bound parameter and every field name is validated against the doctype's schema before it reaches a json path.
func (*Store) ReplaceDocType ¶
ReplaceDocType replaces an existing DocType's definition (PUT semantics). The documents already stored under it are left intact (a schema change never silently drops data); the next write validates against the new schema.
func (*Store) RevokeRole ¶
func (*Store) SeedOwnerIfUnowned ¶
SeedOwnerIfUnowned atomically grants `user` the System Manager role IFF the org has NO role assignment yet — the trust-on-first-use owner seed. It is a SINGLE conditional INSERT (INSERT ... SELECT ... WHERE NOT EXISTS), so the "is the org unowned?" test and the insert are one statement: under concurrency EXACTLY ONE caller's row lands (the rest match an org that now has a role and insert nothing). Returns whether THIS caller became the seeded owner (RowsAffected==1).
This replaces a check-then-insert (a SELECT for existing roles, then an INSERT) whose window let several simultaneous first-callers each seed themselves System Manager (Red measured 3–6). A UNIQUE index is deliberately NOT used: multiple SMs are legitimate later, granted explicitly via AssignRole — only the AUTOMATIC first-seed must be singular.
func (*Store) SetDocStatus ¶
func (s *Store) SetDocStatus(ctx context.Context, org, doctype, name string, from, to int) (Document, error)
SetDocStatus transitions a document from `from` to `to` atomically, verifying the current status equals `from` (else errBadState). This is the ONE path for submit (0→1) and cancel (1→2); the check-and-set is inside a transaction so two concurrent submits can't both win.
func (*Store) UpdateDocument ¶
func (s *Store) UpdateDocument(ctx context.Context, org string, dt *DocType, name string, data map[string]any) (Document, error)
UpdateDocument replaces a document's data. Only a DRAFT (docstatus 0) is editable — a submitted/cancelled document is immutable (errBadState) so the submit lifecycle can't be bypassed by a plain PUT. `data` is already validated.