The seams library
seams is the Python library that reads Semantic Seams models without executing them. Its import name is seams; its distribution name is semanticseams (packages/seams/pyproject.toml). Its only dependencies are rdflib and lxml, so importing it never pulls in SpiffWorkflow, FastAPI or seams_runtime, and the package is strictly type-checked (uv run pyright).
v1 is a uv workspace member only and is not published to PyPI (D031). Install it with the rest of the workspace:
uv sync --all-packagesThe generators in seams_gen, the viewer and seams_runtime all read models through it. To write a generator of your own, follow Write your own generator.
API
import seams
model = seams.load("examples/chat-app/chat-app.trig")
model.hash # "sha256:..."
model.payloads() # [("chat_process.bpmn", Path(...)), ("routing.dmn", Path(...))]seams.load(path=None, *, ontology_dir=None, check_versions=True, check_imports=True) -> Modelloads a model.- A file is parsed as TriG; payloads resolve against its directory.
- A directory loads every
*.trigdirectly inside it (not recursive, sorted by file name) into one Dataset; payloads resolve against the directory. A directory with no*.trigraisesFileNotFoundError. - Without a path, the
SEAMS_MODELenvironment variable names the model. An explicit path always wins; an unset or empty variable raisesValueError. - The model passes the vocabulary version check first (see Vocabulary versions), then the ontology import check (see Ontology imports).
Modelexposespath,base_dir,files(the TriG files loaded),ds(the merged rdflibDataset,default_union=True),subject,curie(term),label(iri),payload_path(model_iri)and:compact(term): the inverse ofcurie. It returnsprefix:localfor the longest bound namespace the IRI starts with (ties broken by prefix), wherelocalis the last fragment or segment, so a BPMN element<…approval_bpmn#approve_expense>shows as:approve_expense. An IRI no namespace matches comes back as<iri>.compact(term, whole=True)keeps everything after the namespace (:approval_bpmn#approve_expense), whichcurieexpands back to the same IRI.label(iri, strict=True): the singlerdfs:label, where the default falls back to the local name. None or several raiseResolutionError(iri, rdfs:label)("expected exactly one, found none"), the rule the generators use.hash: computed once per instance.migrations: the vocabulary migration steps applied on load, sorted.ontology_dir: the ontology directory the checks used, or None when there is none.imports: the vendored ontologies the model's ontology imports, sorted by version IRI;dataset_imports: the ontology models of the same Dataset they import (DatasetOntology), sorted by version IRI;import_statuses: theImportStatusof each import, read even withcheck_imports=False.payloads():(relative posix path, absolute path)for everyseam:renderPayload, sorted by relative path. A missing payload raisesResolutionError.
seams.model_hash(path),seams.payload_path(payload_iri, base_dir)andseams.model_dir(path)(the directory payloads andhandlers/resolve against: a directory model itself, else the TriG file's parent) are module-level helpers.- Errors:
SeamsError(base;http_status,to_dict()) andResolutionError(subject, predicate, detail=""), also aLookupError, raised when a graph hop is missing. - Namespaces:
SEAM,COMM,DEC,PROC,STATE,HYDRA,ODRL,DEPLOY,IFML,ARCH,DASH(dash:editors),VERIFY(ADR-0014 scenarios and examples), pluslocal_name(iri). ModelGraph(path, dataset=None, *, ontology_dir=None, check_versions=True, check_imports=True)(andModelGraph.load(path)) is aModel, taking the same load options, plus the one-of-each resolvers the runtime and the generators share, such aslandmark_page(),task_iri(bpmn_id),triggering_operation(process),is_channel_started(process),decision_binding(task),implementing_container(operation),scheduling_container(process)(the one container thatseam:schedulesthe process, ADR-0037; none or several raiseResolutionError(process, seam:schedules, <sorted container IRIs>)),lifecycle_for(shape),policy(policy)andshape_graph().published_channel(element)is the onecomm:Channelthe elementseam:publishesTo(Nonewhen it declares none, more than one raises), andpublished_channels(process)lists the distinct channels any element of the process's BPMN model publishes to, sorted by IRI; the runtime streams deltas only on the first and closes all of the second on failure (D046). Each answers from graph facts and raisesResolutionErrornaming the subject and predicate when a hop is missing.landmark_page()andtask_iri()are view-backed:landmark_page()ismodel.interfaces.landmark(), andtask_iri()resolves throughmodel.processesacross any number of BPMN models (the graph element with that id, else the one BPMN file declaring it; an id in several models raises). The pages an app serves aremodel.interfaces.served_pages(). Results are frozen dataclasses (Transition,DecisionBinding,Container,OperationPath,ChannelBinding,ChannelStart,ProcessFacts,Lifecycle,PolicyFactsand the rest), anddmn_decision_ids,dmn_input_typesandbpmn_start_event_idsread the payload XML.ModelGraphis a transitional facade over the views: every resolver reads throughmodel.processes,operations,channels,lifecycles,policies,interfaces,architecture,shapesanddecisions(onlyshape_graph()copies triples, for SHACL).task_element(task),shape(iri),page(iri)andcomponent(iri)hand back the interned view element, andhas_lifecycle(shape),governing_policies(element)andrendered_components()answer the runtime's yes/no and list questions;Lifecycle.transitionslists the machine's state:hasTransition IRIs.store_plan()resolves where the runtime keeps its rows (ADR-0011) andstore_health()reports what is wrong with that; see Store resolution.operation_implementation(op)reads what serves an operation (ADR-0012); see Operation implementation.seams_runtimere-exports it unchanged. Three of its result types share a name with a view class (Container,Lifecycle,Transition): import those fromseams.graph(orseams_runtime.model); the top-level names are the view classes.
Vocabulary versions
seams.load runs the ADR-0021 load check. Each vocabulary in ontology/<name>.ttl carries owl:versionIRI <https://w3id.org/seams/<name>/MAJOR.MINOR>; a model declares the versions it targets as owl:imports in <urn:seams:graph:manifest>, and a vocabulary it does not declare targets 1.0.
model = seams.load("examples/expenses/chapter-01", ontology_dir="tests/fixtures/vocab-v2")
model.migrations # (MigrationStep(vocabulary='seam', source='1.2', target='2.0'),)DEFAULT_ONTOLOGY_DIR: the repository'sontology/, whichloadchecks against when given noontology_dir(D060). When it does not exist (an installed copy ofseams) the check is skipped; an explicitontology_dirthat does not exist raisesFileNotFoundError.check_versions=Falseloads the model as written.MANIFEST_GRAPH:<urn:seams:graph:manifest>, the graph holding the declarations.shipped_versions(ontology_dir):{name: (major, minor)}from each vocabulary'sowl:versionIRI. A vocabulary without one raisesResolutionError(<ontology>, owl:versionIRI).declared_versions(ds, shipped): the versions a Dataset declares, by name. Imports of other ontologies are ignored.check_versions(ds, ontology_dir): the check itself, migratingdsin place and returning theMigrationSteps applied. Per vocabulary: the same major with a minor at most the tool's loads as written; a newer version raisesResolutionError(<urn:seams:graph:manifest>, owl:imports, "targets seam 2.1; this tool supports seam up to 2.0"); an older major runsontology/migrations/<name>/<k>-to-<k+1>.ru(SPARQL Update) per step, in sorted vocabulary order and ascending step order, then replaces the declaration with the tool's version IRI. Disagreeing declarations, an unpublished version, a missing step and a step callingNOW(),RAND(),UUID(),STRUUID()orBNODE()raise the sameResolutionErrorbefore the Dataset changes.MigrationStep(vocabulary, source, target): one applied step, versions as"MAJOR.MINOR"strings.version_iri(name, (major, minor)):https://w3id.org/seams/<name>/MAJOR.MINOR.
Migration happens in memory only: no file is written and model.hash is unchanged.
Ontology imports
A seam:Ontology model is also an owl:Ontology (ADR-0022); its owl:imports name the version IRIs of the ontologies it builds on. seams.load resolves them offline, first against the other ontology models of the same Dataset by owl:versionIRI (ADR-0030 section 4), then against the files in <ontology_dir>/vendor/ (VENDOR_DIR), never over the network, and never merges their triples into model.ds, so model.hash, SHACL and the generators see only the model.
model = seams.load("model.trig")
model.imports # (VendoredOntology(ontology=…/gistCore, version=…/gistCore14.0.0, path=…),)vendored_ontologies(ontology_dir): aVendoredOntology(ontology, version, path)pervendor/*.ttl, read from the file's ownowl:Ontologyandowl:versionIRI, sorted by version IRI. A file without exactly one of each raisesResolutionError.vendored_graph(vendored): the file's triples as a read-only graph.dataset_ontologies(ds): aDatasetOntology(ontology, version)perseam:Ontologymodel ofdswith oneowl:versionIRI, sorted by version IRI;dataset_imports(statuses)the ones a set of import statuses resolves to.import_statuses(ds, ontology_dir): anImportStatus(ontology, imported, vendored, detail, dataset)per import of eachseam:Ontologymodel, sorted by (ontology, import);resolvedanderror(theResolutionError, or None) derive from it.check_imports(ds, ontology_dir): the checkloadruns, returning the vendored ontologies. The version IRI of another ontology model ofds, or of a vendored file, resolves; an ontology never resolves its own. The first fault raisesResolutionError(<ontology>, owl:imports, detail): a mismatch, also for the bare ontology IRI ("imports gistCore14.1.0; the vendored version is gistCore14.0.0", or for a dataset ontology"imports <…/finance_ontology/2.0>; the dataset's finance_ontology is version <…/finance_ontology/1.0>"), a SemanticSeams vocabulary version, which belongs in the manifest graph (ADR-0021), or anything else ("imports <iri>, which no file in ontology/vendor/ declares").check_imports=Falseskips it.
model.ontologies lists the ontology models as Ontology (version_iri, imports, classes()). An OntologyClass has label, definition (skos:definition), properties (by rdfs:domain) and superclasses: a sorted tuple of Superclass(iri, label, definition, source), where source is the model that declares the superclass, the version IRI of the resolved import that declares it (labelled from its skos:prefLabel), or None when neither does.
Views
The views read every model of a layer at once. Several BPMN, IFML or statechart models in one graph are the normal case, not an error.
model = seams.load("examples/expenses/chapter-08")
api = model.architecture.containers().by_iri(":expense_api")
api.executes.iris() # (…approval_process, …payout_process)
op = model.operations.by_iri(":submit_expense_op")
op.exposed_by.only() is api # True: one object per IRI and model
op.governed_by.only().permissions # (Permission(target=…, action=odrl:use, …),)
model.lifecycles.only() # ResolutionError: two state machines
model.interfaces.served_pages().iris() # (…expenses_page, …payouts_page)
payout = model.processes.for_bpmn_process("payout_process").only()
payout.channel_starts.only().channel # Channel(iri=…payouts, …)
model.processes.element("choose_payout").only().decided_by.decision_id # "payout_method"Many[T]is an immutable tuple, always built in a documented order (bystr(iri)unless the view says otherwise). It remembers thesubjectandpredicateit was read from: the model andrdf:typefor a layer's list, the element and the edge predicate for an edge..only()returns the single item. It raisesResolutionError(subject, predicate, "expected exactly one, found [...]")for zero items or more than one..by_iri(iri_or_curie)returns the item with that IRI, and raisesResolutionErrorwhen it is absent..where(pred)filters the items and keeps the same subject and predicate..iris()returns the IRIs.
Elementis the frozen dataclass every view item extends. Its fields areiri,label(rdfs:label, else the local name) andmodel(theseam:Modelit isseam:inModel, or None). It also has these properties:raw: the rdflib node.edges: every outgoingseam:triple as(predicate, object), sorted.graph: the Dataset.
Elements compare and hash by type and IRI, and assigning to one raises
FrozenInstanceError. A per-model registry (model.views) builds each view once and interns each element, so a cross-layer edge and the layer's own list return the same object. An edge to a node of a type no view covers yet returns a plainElement.View collections.
model.architectureis anArchitectureandmodel.interfacesanInterfaces: objects with the list methods below.model.processesis aProcessesandmodel.lifecyclesaLifecycles, both aManywith extra lookups.model.operations,model.channels,model.shapes,model.decisions,model.policies,model.scenariosandmodel.decision_examplesare plainManys.model.architecturehasdomains(),contexts(),systems(),containers(),deployment_nodes(),environments()andserving_containers()(each container thatseam:executesa process orseam:exposesan operation anifml:Eventseam:submitsTo: the ones that serve routes and hold process state).Domain(ADR-0030) hasclassification(thearch:Core,arch:Supportingorarch:GenericIRI; none, several or a literal raiseResolutionError(domain, arch:classification)),classification_label(itsrdfs:label: the model's own when it states one, else the shipped arch vocabulary's, such as"core"), andsystemsandcontexts(thearch:Systems andarch:BoundedContexts itarch:contains).BoundedContexthasdomain(the onearch:Domainthatarch:containsit, elseResolutionError(context, arch:contains)),models(theseam:Models itseam:owns),ontology(the one owned model whose notation isseam:Ontology, anOntology, elseResolutionError(context, seam:owns)) andcontainers, derived: eacharch:Containerthatseam:presentsan owned model orseam:executesa process in one. A model that declares no context has emptydomains()andcontexts().System.containers(thearch:Containers itarch:contains).Containerhastechnology,exposesandconsumes(aMany[Operation | Channel]),executes,schedules,deployed_to,depends_on(C4arch:dependsOn) andsystems.service_bindingsis a read-only mapping fromdeploy:keytodeploy:serviceOf, sorted by key.store_kindslists itsdeploy:storeKindIRIs (sorted),stored_shapesthe shapesseam:storedInit, andconnecting_bindingsitsbindingswhoseconnects_tois set.- Its
deploy:facts are frozen dataclasses:artifact(anArtifactwithreference,versionandbuild, aBuildwithcontextanddockerfilewhen it is built from source, or None),ports(Port:container_port,published_port),volumes(Volume:name,mount_path),healthcheck(Healthcheck:probe,interval, or None),replicas,deploy_depends_on(start order) andbindings(Binding:key,kinds,value,secret_ref,secret_name,secret_file,service_of,connects_to, by key), andproxies(the container itdeploy:proxies, or None).connects_tois the store container a bindingdeploy:connectsTo; it sits beside the value source and is not one ofkinds. They sit on blank nodes, so a missing or duplicated value raisesResolutionError(container, predicate). These are the shared facts. - Environments (ADR-0015):
overrideslists itsdeploy:overrides asOverrides (environment, andartifact,bindingsandreplicas, each None when the override does not state it), by environment IRI.for_environment(environment)applies the override rule and returns aDeployment(environment,artifact,bindings,replicas): an override's artifact or replicas replace the shared ones whole, its bindings replace the shared binding with the same key or add one, and anything it does not state is shared. An override that is nodeploy:Override, states another predicate, names no singlearch:Environment, shares its environment with another, binds a key twice or changes a binding'sdeploy:connectsTostore raisesResolutionError(container, predicate); the shapes report the same faults. DeploymentNodehashostsandenvironments;Environmenthasnodes.
model.operationsandmodel.channels:Operationhasmethod,path,accepts,returns,media_kind,request_media_type,implemented_by,exposed_by,consumed_by,governed_by,renders(the components itseam:rendersComponent) andsubmitted_by(the events thatseam:submitsToit).pathis aPathTemplate(template,mappings:PathMappings withvariable,propertyandproperty_iri, sorted by variable,variables).acceptsandreturnsare aShapeor None.errorsis aMany[Status]: the error statuses ithydra:possibleStatus, each with acomm:errorCode(ADR-0013).error(code)is the one with that code, elseResolutionError(operation, comm:errorCode).Status(ahydra:Status) hasstatus_code(hydra:statusCode),error_code(comm:errorCode),returns(the error shape itseam:returnsShape, or None) andoperations(those it is thehydra:possibleStatusof).Channelhastransport,event_name,path,accepts,exposed_by,consumed_by,published_byandsubscribed_by.
model.shapes: aShapehas these members:fields(): aMany[Field]sorted bysh:order(fields without one last), then by path IRI. AFieldhaspath,name(itssh:name, else the path's local name: the wire name, ADR-0023),label(rdfs:label, else the name),datatype,min_count,max_count,order,required,max_length(sh:maxLength),editor(dash:editor, e.g.DASH.TextAreaEditor) andoptions: thesh:inliterals in list order, or therdfs:labels of its categorymembers(ansh:inof IRIs in list order, or the members of thegist:Categorysubclass incategory, in lifecycle order when they are states).nodeis aFlatNodefor a flattenedsh:nodefield: its nestedshape,node_class,free_pathand thefixed(path, sh:hasValue)pairs; the field's datatype, options andmax_lengththen come from the free property. An empty category, a member without exactly onerdfs:label, two members with one label, members from two lifecycles, ansh:inmixing IRIs and literals and ansh:nodethat does not flatten raiseResolutionError(shape, predicate, detail).target_class: theOntologyClassit names withsh:targetClass, or None; a class noseam:Ontologymodel declares raisesResolutionError.lifecycle,read_by,written_byandstored_in(the store containers it isseam:storedIn).aggregate: theAggregateit is aseam:aggregateMemberof, or None; a shape two aggregates list raisesResolutionError(shape, seam:aggregateMember, detail)naming both.valid_examplesandinvalid_examples: the IRIs of the nodes itverify:validExampleorverify:invalidExample(ADR-0014), by IRI.example_values(node):(Field, Literal)pairs, one for each value an example node has on one of the shape's fields, infields()order (a field's values by lexical form). A category member IRI reads as its label and a flattened field reads through the nested node to its free path. The smoke generator fills paths and request bodies from them.
model.lifecycles: oneLifecycleperstate:StateMachine.model.lifecycles.transitions()lists the transitions of all of them.Lifecyclehasinitial,states(),finals(),transitions(),shapeandshapes(every shape naming it, by IRI).Transitionhasfrom_state,to_state(Stateobjects),from_states,trigger,lifecycle,lifecycles(every machine thatstate:hasTransitionit) andfired_by.
model.aggregates: oneAggregateper subject ofseam:aggregateMember(ADR-0035), by IRI: theowl:Classorsh:NodeShapethat names the shapes it commits in one store transaction. An aggregate with no members declares no boundary and is not listed.Aggregatehasmembers(its listedShapes, by IRI, never derived fromsh:targetClass) andlifecycle(theLifecycleit names, or None).
model.scenariosandmodel.decision_examples: what the model says it should do (ADR-0014), by IRI.Scenariohasprocess(aProcess),actor(the party IRI),givens,steps,failures,expect_state(aStateor None) andexpect_end_event(an IRI or None). AFailurehasoperation(anOperation) andstatus(theStatusit fails with), sorted by operation IRI. AGivenhasfield(thesh:pathIRI) andvalue(aLiteral), sorted by field. AStephastask(aProcessElement),actorandgivens, inverify:steplist order.DecisionExamplehasdecision(aDecision),givensandexpected: theExpectedOutputs (variable,value), sorted by variable.- A missing or duplicated fact, or a field given twice, raises
ResolutionError(subject, predicate); the ADR-0014 shapes flag the same faults.
model.policies: aPolicyhaspermissionsandgoverns.permissionsholdsPermissiontuples sorted asModelGraph.policysorts them.governslists the operations and tasks that areseam:governedBythe policy.model.interfaces(IFML) hasmodels(),pages(),components()(every element typed with an IFML component class),events(),flows(),landmark()andserved_pages(). Each list covers every IFML model;.where(lambda e: e.in_model(":my_ifml"))keeps one model's members (Element.in_model(model)is true for eachseam:inModelit states).landmark()is the one page withifml:isLandmark true. Zero or several raiseResolutionError(model, ifml:isLandmark, "expected one ifml:ViewContainer with ifml:isLandmark true, found [...]").served_pages()lists the pages the generated app serves at theirifml:path: the landmark first, then by IRI every other page with a path that does not host a task form.InteractionModelhaspages()andpresented_by(theContainerthatseam:presentsit).Pagehaspath,title(ifml:title),is_landmark,is_xor,is_default(ifml:isXOR/ifml:isDefaulttrue),pages()(the nested pages it contains),parents(the pages that contain it),visible_when(lifecycleStates, as onComponent),interaction_model,components()(byifml:order, then IRI; a non-integer order raisesResolutionError(component, ifml:order)) andhosts_task_form(a part it contains is the form a user task isseam:completedOn, ADR-0009).Componenthaskind(Form,Details,List,ViewComponent),types(its concrete classes amongifml:List,ifml:Detailsandifml:Form, in that order),order,page,screens(every page that contains it),reads,writes,parameters()(labels),parameter_bindings,events(),data_flows,rendered_by(the operations that render it),visible_when(the lifecycleStates it renders in) andcompletes(the user tasks completed on the form).parameter_bindingsholds oneParameter(iri,label,bound_to: thesh:pathit isifml:boundTo, or None) per parameter, by IRI.Eventhastrigger,component(exactly one owner, as aComponent),owners(every element thatifml:hasEventit, typed as the graph says),triggers(aProcess),triggers_all(every process it triggers),governed_by(aMany[Policy]),submits_to(anOperation),flows,navigates_to(aPage) andupdates(the components its navigation flows swap).Flowis anifml:NavigationFloworifml:DataFlow. It haskind,sourceandtarget(exactly one each),sources,targets,swap(ifml:Replace,ifml:Appendorifml:Prepend; a missing one raisesResolutionError(flow, ifml:swap, "expected exactly one, found none")) andbinds: oneParameterBinding(source,targetparameter IRIs) perifml:binds, sorted.event.flows.where(lambda f: f.kind == IFML.NavigationFlow).only()raisesResolutionError(event, ifml:source)when there is none or several.
model.processes: oneProcessperproc:Process, by IRI.Processhasbpmn_model(the model it isseam:detailedBy),bpmn_models(all of them, by IRI),bpmn_path,bpmn_process_ids(read from the BPMN XML),executed_by,scheduled_by(the containers thatseam:schedulesit),triggered_by,triggering_operations(the operations its triggering eventsseam:submitsTo),invoked_operations(what it or an element of its BPMN modelseam:invokes),correlation_key,correlation_parameter,correlation_parameters,channel_starts(eachMessageStart: a message start event and theChannelitseam:subscribesTo),elements(),element_iri(bpmn_id),error_boundaries()andtimer_events().- A
ProcessElementis a task or event whose IRI is<bpmn_model>#<bpmn id>. It hasbpmn_id,kind(UserTask,ServiceTask,ErrorBoundaryEvent,TimerStartEvent,TimerCatchEvent, ...),types(itsproc:classes),process,invokes,reads,writes,fires,decided_by,completed_on(aComponent),publishes_to,subscribes_toandcatches(theStatusaproc:ErrorBoundaryEventseam:catches, ADR-0013). error_boundaries()joins each liftedproc:ErrorBoundaryEventto the XML: oneErrorBoundary(iri,nodeandtask, theFlowNodes of the boundary and the task it is attached to,operationthat taskseam:invokes,statusitseam:catches, andcode, the errorCode of itsbpmn:error) per error boundary on an invoking task, in flow order. Where the graph and the XML disagree it raisesResolutionErrornaming the boundary:seam:catchesfor a boundary that is not lifted, catches no status or several, catches a status that is not an error of the invoked operation or whosecomm:errorCodediffers from the XML's, or is lifted with no matching boundary in the XML;bpmn:errorEventDefinitionfor a catch-all;bpmn:errorRef/bpmn:errorCodefor a ref that names nobpmn:erroror one without a code.timer_events()joins each liftedproc:TimerStartEventandproc:TimerCatchEventto the XML (ADR-0037): oneTimerEvent(iri,node,kind,attached_to, the user task'sFlowNodefor a boundary,interrupting, itscancelActivity,expression, the local name of itstimeDate,timeDurationortimeCycle, andbody, that element's text as written;is_startandexpression_predicate) per in-scope timer, in flow order. In scope are a processstartEvent(not one inside an event sub-process), anintermediateCatchEventand aboundaryEventon aproc:UserTask. Where the graph and the XML disagree it raisesResolutionError(<event>, bpmn:timerEventDefinition, ...): an in-scope timer not lifted or lifted with the wrong type, a lifted timer out of scope or with no timer event in the XML, a second event definition, and a definition with no expression or more than one. It never parses the expression; the runtime does (bpmn.TimerDefinitionandTimerExpressioncarry it as written).model.processes.for_bpmn_process(pid)lists the processes whose BPMN file declaresbpmn:processpid.model.processes.element(bpmn_id)lists the elements with that id across every BPMN model. Call.only()on either; an id used in two BPMN models raises.elements()lists what the graph states, by IRI.nodes()walks the BPMN XML in flow order instead: every flow node of everybpmn:processinbpmn_path(processes in document order), lifted into the graph or not, so start and end events and gateways are included.tasks()andgateways()filter that walk andlanes()lists theLanes (id,name,node_ids). Each is built once.- A
FlowNodehasiri(element_iri(bpmn_id)),node(theseams.bpmn.BpmnNode),process,element(the sameProcessElementobject aselements(), or None when the graph does not type the IRI),bpmn_id,tag(userTask,exclusiveGateway,startEvent, ...),name,lane,incoming,outgoingandpaths(SequenceFlows withid,name,source,targetandcondition),kind, the element's edges (invokes,decided_by,completed_on,fires;reads,writes,publishes_to,subscribes_to), which are None or empty when unlifted,catches, andstarts_from(theChannelofchannel_startswhose event it is).
pythonapproval = model.processes.for_bpmn_process("approval_process").only() for gw in approval.gateways(): print(gw.bpmn_id, [(f.name, f.target, f.condition) for f in gw.paths]) # route_gateway [("auto", "record_approval", 'route == "auto"'), …]A
BpmnNodealso hasattached_to(a boundary event'sattachedToRef),error_ref(itserrorEventDefinition'serrorRef) andis_error_boundary; aBpmnProcesshaserrors(the file'sbpmn:errors asBpmnError:id,name,code) anderror(id).seams.bpmn(parse_bpmn) andseams.dmn(parse_dmn,decision_table) are the lxml-only parsers behind these joins; they read any BPMN or DMN namespace version and raiseResolutionErrornaming the element and attribute (bpmn:targetRef,dmn:decisionTable, ...) that failed.model.decisions: eachdec:Decisiona task isseam:decidedBy. It hasdecision_id,dmn_model,dmn_path,inputsandoutputs(InputBindingandOutputBinding, asModelGraph.decision_bindingreturns them),readsanddecides_for.Decision.tableis the DMN decision table, read fromdmn_pathwith lxml byseams.dmn(parsed once per file): aDecisionTablewithdecision_id,decision_name,table_id,hit_policy(UNIQUEwhen the file states none),aggregation,inputs(TableInput:id,label,expression,type_ref),outputs(TableOutput:id,label,name,type_ref) andrules(Rule:id,inputsandoutputsas the entry texts,description), all in document order.- A decision without a
decisionTable, or a rule whose entries do not match the table's inputs or outputs, raisesResolutionErrornamingdmn:decisionTable,dmn:inputEntryordmn:outputEntry.
pythontable = model.processes.element("route_expense").only().decided_by.table [(r.id, r.inputs, r.outputs) for r in table.rules] # [("rule_auto", ("<= 100",), ('"auto"',)), …]
Single-valued edges (accepts, lifecycle, shape) return None when the fact is absent. When the graph states the fact twice, they raise ResolutionError naming the element and the predicate.
SPARQL and canned questions
model = seams.load("examples/chat-app/chat-app.trig")
model.sparql("SELECT ?p WHERE { ?p a ifml:ViewContainer }") # [{"p": URIRef(…chat_page)}]
model.question("impact-of-node", node=":db01") # [{"impacted": URIRef(…ConversationShape)}, …]
seams.question_names() # ["c4-l1-rollup", "impact-of-node", …]model.sparql(query, **bindings)runs a read-only SELECT againstmodel.dswithseams.PREFIXESdeclared (seam,comm,dec,proc,state,hydra,odrl,deploy,ifml,arch,rdf,rdfs,sh,xsd), so it needs no PREFIX lines. It returns a list of dict rows (variable name to rdflib term; unbound variables are absent), in the query'sORDER BYorder when it has one, else sorted by the rows' string values.- Each keyword binds the variable of that name through
initBindings, never by string interpolation. URIRefs and Literals pass through, astrcontaining:is expanded withmodel.curie(":db01", a full IRI), and any other value becomes a Literal. - An update (
INSERT,DELETE,LOAD,DROP, ...) raisesValueErrorbefore anything runs. So does a non-SELECT query; useseams.sparql.sparql(model, query)for the raw rdflibResultof an ASK or CONSTRUCT.
- Each keyword binds the variable of that name through
model.question(name, **bindings)runs one of the canned questions indocs/queries/, which ship as package data (seams/queries/*.rq, byte-identical copies;tests/test_seams_query.pyguards the drift).seams.question_names()lists them andseams.load_question(name)returns the text. An unknown name raisesKeyErrorlisting the available ones; a name is never turned into a path unchecked.- The coverage queries in
docs/queries/coverage/load by folder-qualified name:model.question("coverage/unfired-transitions")andmodel.question("coverage/policies-without-deny"), each returning no rows when the model is covered.seams.question_names("coverage")lists them;seams.question_names()with no folder still lists only the top-level questions. Any folder other thancoverageraisesKeyError.
Coverage
seams.coverage answers what the model declares that no scenario or test exercises (D054). It covers three things:
seams.unexercised_paths(model, process=None)lists the BPMN paths noverify:Scenarioruns, asProcessPathrecords. Each record hasprocess,nodes(theFlowNodes in walk order),iris,bpmn_ids,start,endandscenarios.seams.process_pathsreturns every path along with the scenarios that run it. Sequence flows live only in the BPMN XML (ADR-0007), so this is a Python join, not SPARQL. A path is a simple walk from abpmn:startEventto a node with no way out. A boundary event counts as one more way out of its task. A walk that can only loop back is dropped.A scenario's run is a walk: a path of its process, then, when a node on that path
seam:publishesToa channel, a path of a process whose start eventseam:subscribesToit (one hop). A scenario exercises a walk when all of these hold:- its
verify:expectEndEventis on the walk; - a node on the walk
seam:firesa transition into itsverify:expectState; - its steps are exactly the user tasks on the walk, in order, up to the last of those expectations, leaving out a task the walk leaves through its timer boundary (the timer fires while the task is open, so no step completes it);
- the walk invokes every operation a
verify:failurenames, leaves each such task through the error boundary that catches the failure's status, and passes an error boundary only that way; - no step's task comes after a timer boundary on the walk. A timer boundary needs no failure: a scenario's run lets the timers its instance waits on fire once its steps are done (ADR-0037).
The scenario then exercises its own process's path. It also exercises the started process's path when one of its expectations lies on it.
- its
seams.unfired_transitions(model, lifecycle=None)runscoverage/unfired-transitionsand returns aMany[Transition].seams.policies_without_deny(model, policy=None)runscoverage/policies-without-denyand returnsUndeniedPermissionrecords (policy,target,assignee).seams.core_operations_untested(model, context=None)runscoverage/core-operations-untested(ADR-0030 section 6) and returns aMany[Operation], by (domain, context, operation): eachhydra:Operationwhoseseam:inModelmodel a context in anarch:Coredomainseam:owns, that is notseam:implementedByacomm:PythonHandlerand that no scenario reaches through the process it starts or the user task it completes. A dataset with no domain has no rows.
On chapter 9 the nine scenarios run every path: both outcomes of each route, the two notify-failed escalations (:notify_failed_approved and :notify_failed_rejected fail :notify_manager_op) and both payout paths (:paid_in_batch and :paid_now expect the payment's final state). Both SPARQL reports are empty too.
Decision checks
seams.decisions_check finds decision faults that the runtime would otherwise hit only while running (backlog K2). Each fault is one DecisionFinding, with subject (an IRI), fact and message. The lists are sorted.
seams.type_ref_findings(model)checks the input types of eachdec:Decisionthat has adec:payloadElement. Eachdec:inputis matched to the table input whose inputExpression text is itsdec:variable, which is how the engine matches them. The table input'stypeRefmust then accept thesh:datatypeof thedec:fromPathproperty. The property is looked up on the shapes the decisionseam:reads, or on every shape if it reads none. The accepted datatypes are:number:xsd:decimal,xsd:integerand its subtypes,xsd:doubleandxsd:float;string:xsd:string;boolean:xsd:boolean;date:xsd:date.
A
dec:Lengthinput is always a number, so its typeRef must benumber. A missing or unlisted typeRef gives no finding, and neither does a property without a datatype. In each finding the subject is the decision and the fact is thedec:fromPathIRI.seams.condition_findings(model)checks BPMN conditions. Spiff evaluates eachconditionExpressionas Python over the task data. Every free name a condition reads (seams.decisions_check.condition_names) must be set by a node upstream of the flow, in the same process. Two kinds of node set names:- a business-rule task sets the
dec:processVariables and inputdec:variables of the decision it isseam:decidedBy; - a user task sets the fields of the shape it writes.
In each finding the subject is the flow IRI (
<bpmn model>#<flow id>). The fact is the variable, or the expression itself if it does not parse.- a business-rule task sets the
seams.table_findings(model)checksUNIQUEdecision tables (backlog X13): one finding per pair of overlapping rules and one for the table's first uncovered input, naming the rules or the input. The subject is the decision and the factdmn:rule. It reads input entries that are-, number comparisons, ranges, quoted strings or comma lists of these; a table with any other entry is skipped, not guessed at.seams.decision_findings(model)returns the findings of all three checks.
A decision or process whose payload file is missing is skipped. Any other ResolutionError met while reading one becomes a finding. Every example model yields no findings.
Model review checks
seams.review_check flags facts the model states twice or not at all (backlog R1). Each finding is one ReviewFinding, with subject (an IRI), fact and message. The lists are sorted. A review finding flags the model; it never refuses it, and the checks do not raise.
lifecycle_label_findings(model): ash:inlist on a shape with aseam:lifecyclewhose members are allrdfs:labels of that lifecycle's states. The subject is the shape, the fact the property'ssh:path, and the message names the state IRIs to use instead.shape_class_findings(model): ash:classwhose value is ash:NodeShape. The subject is the shape, the fact thesh:path.target_class_findings(model): ash:NodeShapewith nosh:targetClassthat is not itself anrdfs:Classorowl:Class. The fact issh:targetClass.undeclared_path_findings(model): ash:pathIRI that nordf:Property,owl:ObjectProperty,owl:DatatypePropertyorowl:AnnotationPropertydeclares. Path expressions such assh:inversePathare skipped. The subject is the path, the factsh:path.seams.review_findings(model)returns the findings of all four.
The chat app has eleven: its MessageShape status list, sh:class :MessageShape, both shapes and all seven paths. Backlog O1 and O2 remove them.
Domain profiles
seams.profiles resolves the domain profile a model attaches to with seam:conformsTo (ADR-0018, ADR-0033 section 5). resolve_profile(ds, model_dir) finds the profiles/*/profile.ttl in model_dir (the directory of the loaded TriG file, seams.model_dir(path)) that declares the IRI a seam:Profile. It returns None when the model states no seam:conformsTo, and then reads no file. Otherwise it returns a Profile:
iri: the profile IRI;classesandlabels: theseam:offersClassclasses, sorted, and theirskos:prefLabel, elserdfs:label;generators: theseam:usesGeneratornames, sorted;shapes: one graph parsed from theseam:profileShapesfiles;graph: the parsedprofile.ttl;pills: each pill shape (ash:NodeShapeinshapeswithsh:severity sh:Infoand a label, ADR-0033 section 4) mapped to its label.
The resolver reads and parses; it does not validate. seams check and the viewer run pyshacl with shapes. A model naming two profiles, a profile no profile.ttl declares or two declare, and a profile or shapes file that is missing or does not parse raise ResolutionError naming seam:conformsTo or seam:profileShapes, with paths relative to model_dir.
Context assembly
seams.context gathers the model facts that the code of a service task depends on (backlog C3). context_for(model, task) takes a Model or ModelGraph and a task given as a BPMN id, CURIE or IRI. It returns a Manifest with task (an IRI), items (ContextItems sorted by kind and ref) and context_hash. Each ContextItem has a kind, a ref (an IRI, or a model-relative path#id for an XML fragment), a hash (sha256:<hex>) and the content that was hashed.
Each item keeps only the facts the task uses. A container is narrowed to its technology and the deploy:key of each binding. Binding values are never read. Graph items hash sorted, canonical N-Triples. XML fragments hash their exclusive C14N. context_hash is sha256 over the items, each framed as kind, NUL, ref, NUL, hash, newline. An unknown task, a task that is not a proc:ServiceTask, or a missing hop raises ResolutionError. The package README lists the rules for each kind.
The model hash
"sha256:<hex>", where each payload is framed as relative path, NUL, decimal length, bytes, sorted by relative path:
- File: the TriG bytes, then the framed payloads. This is byte-identical to the hash embedded in generated code (
model-hash:headers). - Directory: each TriG file framed the same way (its name, sorted by name), then the framed payloads.
Editing or renaming the model or any payload changes the hash.
Store resolution
ModelGraph.store_plan() answers which store the runtime keeps its rows in, from three graph facts (ADR-0011): deploy:storeKind on the store container, a deploy:connectsTo binding on the container that runs the model, and seam:storedIn from each shape. It returns a frozen StorePlan:
| Field | Meaning |
|---|---|
kind | deploy:PostgreSQL or deploy:SQLite (RELATIONAL_STORE_KINDS) |
store | the store container, or None for the default |
container | the runtime container whose binding connects to the store, or None |
key | that binding's deploy:key, the environment variable holding the connection string, or None |
declared | False only for the embedded SQLite default |
sempkm | the SemPKMPlan of a deploy:SemPKM store (ADR-0025), or None |
seams.ModelGraph("examples/chat-app/chat-app.trig").store_plan()
# StorePlan(kind=deploy:PostgreSQL, store=:message_db, container=:chat_api, key="DB_URL", declared=True)Runtime containers are the containers that seam:executes a process or seam:exposes an operation that seam:writes a shape. A model with no connecting binding on one and no seam:storedIn at all gets StorePlan(deploy:SQLite, None, None, None, declared=False). Otherwise the store is the one a runtime container connects to, else the single store the written shapes are seam:storedIn. A deploy:SQLite store is embedded: key may be None and the file path still comes from SEAMS_DB.
Each failure raises ResolutionError(subject, predicate):
| Case | subject | predicate |
|---|---|---|
a store with no deploy:storeKind, or more than one | the store | deploy:storeKind |
a kind not in SUPPORTED_STORE_KINDS ("unsupported") | the store | deploy:storeKind |
| a PostgreSQL store no runtime container connects to | the first runtime container, or the store when there is none | deploy:connectsTo |
a written shape with no seam:storedIn, or stored elsewhere | the shape | seam:storedIn |
| one container connecting to two stores, or two containers to different stores (v1 runs one store per model) | the container | deploy:connectsTo |
A SemPKM store
SUPPORTED_STORE_KINDS is the relational kinds plus deploy:SemPKM (ADR-0025). A SemPKM store holds the entities of the shapes stored in it; process state stays in the relational store, so a runtime container connects to at most one store of each family, and each written shape is stored in one of the two. The SemPKM store resolves as StorePlan.sempkm, a frozen SemPKMPlan, while kind, store, container, key and declared keep describing the relational store (the embedded SQLite default when there is none):
| Field | Meaning |
|---|---|
store | the deploy:SemPKM store container |
container | the runtime container whose bindings reach it |
key | the deploy:key of the binding that deploy:connectsTo it: the base URL |
token_key | the deploy:key of the binding that deploy:authenticatesTo it: the service token |
shapes | the shapes seam:storedIn it, sorted |
The SemPKM rows of the error contract (ADR-0025 section 11):
| Case | subject | predicate |
|---|---|---|
| a SemPKM store no runtime container connects to | the first runtime container, or the store when there is none | deploy:connectsTo |
no binding that deploy:authenticatesTo the store, more than one, or one whose value is not a deploy:secretRef or deploy:secretFile | the connecting container | deploy:authenticatesTo |
| one container connecting to two SemPKM stores, or to two relational stores | the container | deploy:connectsTo |
a shape stored in the SemPKM store with no sh:targetClass | the shape | sh:targetClass |
a nested field of such a shape whose sh:node shape has no sh:targetClass | the nested shape | sh:targetClass |
a gistCore import other than SEMPKM_GIST_VERSION (gistCore 14.0.0) | the ontology | owl:imports |
The shape view's stored_in and the container view's store_kinds, stored_shapes, connecting_bindings and authenticating_bindings read the same facts; Binding.authenticates_to is the store a token binding names.
ModelGraph.store_health() turns the same checks into findings for a model-health tool (the viewer's health panel uses it). It never raises for a model the loader accepts, and returns a list of frozen StoreFinding(severity, subject, predicate, message), sorted by severity (STORE_FINDING_SEVERITIES: "Violation", "Warning", "Info", the SHACL severity names) and then subject:
| Severity | When | subject | predicate |
|---|---|---|---|
Violation | store_plan() raises any of the errors above | the error's subject | the error's predicate |
Warning | a store container (one with a deploy:storeKind, or that a binding deploy:connectsTo or deploy:authenticatesTo) that no shape is seam:storedIn | the store | seam:storedIn |
Info | the model declares no store, so the runtime keeps embedded SQLite at SEAMS_DB | None | None |
Info | the model declares only a SemPKM store, so process state stays in embedded SQLite at SEAMS_DB | None | None |
The warning is reported whether or not the plan resolves, so a declared PostgreSQL container next to an embedded-SQLite runtime is visible:
seams.ModelGraph("examples/chat-app/chat-app.trig").store_health()
# []
seams.ModelGraph("examples/checkout.trig").store_health()
# [StoreFinding(severity="Violation", subject=:order_api, predicate=deploy:connectsTo, message="cannot resolve ...")]SemPKM webhook channels
A channel whose comm:transport is comm:SemPKMWebhook is inbound (ADR-0027): SemPKM POSTs its deliveries to the channel's comm:path on the one container that seam:exposes it, and each changed object of the accepted shape's class starts the processes subscribed to the channel. ModelGraph.webhook_channel_iris() lists those channels, sorted; is_webhook_channel(channel) asks about one; webhook_channel(channel) resolves one to a frozen WebhookChannel, and webhook_channels() resolves them all, sorted by IRI:
| Field | Meaning |
|---|---|
iri, label | the channel |
event_name | its comm:eventName, a WEBHOOK_EVENT_TYPES member ("object.changed") |
path | its comm:path template, which has no variables |
shape, target_class | the shape it seam:acceptsShape and that shape's sh:targetClass |
store | the deploy:SemPKM store the shape is seam:storedIn |
container | the receiving container, the one that seam:exposes the channel |
key, token_key | the deploy:keys of its bindings that deploy:connectsTo and deploy:authenticatesTo the store |
processes | the subscribed channel-started processes (subscribed_processes), sorted |
Each row of ADR-0027 section 10 raises ResolutionError(subject, predicate):
| Case | subject | predicate |
|---|---|---|
no comm:eventName, more than one, or one not in WEBHOOK_EVENT_TYPES | the channel | comm:eventName |
no seam:acceptsShape, or more than one | the channel | seam:acceptsShape |
the accepted shape is not seam:storedIn exactly one store, a deploy:SemPKM one | the shape | seam:storedIn |
the accepted shape has no sh:targetClass, or more than one | the shape | sh:targetClass |
no comm:path, more than one, or one with a hydra:mapping | the channel | comm:path |
| two webhook channels of one container with the same path template | the container | comm:path |
no container seam:exposes the channel, or more than one | the channel | seam:exposes |
| no channel-started process subscribes to it | the channel | seam:subscribesTo |
a subscriber the receiving container does not seam:executes | the container | seam:executes |
an element seam:publishesTo it | the channel | seam:publishesTo |
the container has no binding that deploy:connectsTo the store | the container | deploy:connectsTo |
| no token binding for the store, more than one, or one that is not a secret | the container | deploy:authenticatesTo |
an element of a process the container executes seam:writes the accepted shape | the shape | seam:writes |
comm:SemPKMWebhook and another comm:transport, or a channel that is not a webhook channel | the channel | comm:transport |
seamsh:SemPKMWebhookChannelShape reports the same faults in seams check, so a model that passes the shapes resolves.
ModelGraph.replica_health() reports replicas that would not share state (ADR-0038) in the same StoreFinding form, and the viewer's health panel shows it beside the store findings. Replicas of a serving container share process instances and the live-update stream through a deploy:PostgreSQL store; an embedded or deploy:SQLite store lives in one process. A serving container whose deploy:replicas is above 1 in some environment (under the override rule, or its shared count in a model with no environment) while store_plan() is not PostgreSQL is a Violation with predicate deploy:replicas naming the declared counts and the store kind; the compose generator refuses the same model, and seamsh:ReplicatedStoreShape reports it in seams check. Facts the override rule cannot resolve are a Violation too, and a store plan that cannot be resolved is left to store_health(). The chat app's :chat_api declares 3 on its PostgreSQL store, so it gets no finding.
Operation implementation
ModelGraph.operation_implementation(op) reads the operation's seam:implementedBy (ADR-0012) and returns a frozen Implementation(iri, kind, handler), or None when the operation declares none. kind is one of IMPLEMENTATION_KINDS (comm:HTTPEndpoint, comm:LLMCompletion, comm:PythonHandler); handler is the comm:handler dotted path of a Python handler and None for the other kinds. The operation view's implemented_by lists the implementation nodes.
seams.ModelGraph("examples/chat-app/chat-app.trig").operation_implementation(":completion_op")
# Implementation(iri=:completion_impl, kind=comm:LLMCompletion, handler=None)Each model fault raises ResolutionError(subject, predicate):
| Case | subject | predicate |
|---|---|---|
more than one seam:implementedBy, or one that is not an IRI | the operation | seam:implementedBy |
no rdf:type, several, or one not in IMPLEMENTATION_KINDS | the implementation | rdf:type |
a comm:PythonHandler with no comm:handler, several, or one with a segment that is not a Python identifier | the implementation | comm:handler |
Whether the kind agrees with where the operation is served, and whether a handler imports, are the runtime's checks at load, not the library's.
Errors
Every error the library raises for a model problem is a SeamsError.
SeamsErroris the base class. It carrieshttp_status(500) andto_dict(), a JSON-safe dict withtype,messageandhttp_statusplus the error's own fields, so a route handler can return it as a response body.ResolutionError(subject, predicate, detail="")is raised when a graph hop is missing, duplicated or points nowhere. It is also aLookupError. Itssubjectandpredicateare the IRI strings it is about, and its message readscannot resolve <subject>: missing <predicate> (<detail>):pythontry: model.lifecycles.only() except seams.ResolutionError as err: err.predicate # "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" err.detail # "expected exactly one, found [...]"
Loading itself raises the standard errors: FileNotFoundError for a missing file or a directory without *.trig, and ValueError when no path is given and SEAMS_MODEL is unset or empty.