Skip to content

Verified: The tag model

Each claim the The tag model page makes, with the tests that prove it in the last green CI run: 44 of 45 verified. The claims live in docs/claims/tag-model.yaml.

Last green CI run 16 Oct 2026, 14:48 UTC on c7618d3 · all runtime claims

TAG-001 verified

A tag declared in nautilus.yaml is in scope in every PROGRAM without a VAR_EXTERNAL declaration, typed by its type: (or by what its init: implies), and a program binds only the tags it names.

where the docs say so

TAG-002 verified

Naming a tag in a program binds it but does not create it; a tag exists only once one of four writers has written it: a seed (exists from scan one), a driver input, an operator write through the HMI or POST /api/tags to a declared tag (an undeclared name is refused), or the program assigning it (a coil write creates the tag).

where the docs say so

  • pass TestTagDefs in runtime
  • pass TestWriteTag in server
  • pass TestScanReadOfUnwrittenTagFaults in runtime
  • pass TestDriverInputAndProgramCoilCreateTags in runtime

TAG-003 verified

Reading a tag nobody has ever written stops the scan with "undefined tag <name>" instead of running on a silent zero.

where the docs say so

  • pass TestScanReadOfUnwrittenTagFaults in runtime

TAG-004 verified

An output tag seeded with Init (or any seeded setpoint/state) exists from scan one, so a seal-in latch that reads its own coil does not fault on the first scan.

where the docs say so

TAG-005 verified

A tag with neither a type: nor an init: that a program names without a declaration is refused with a message telling the author to give it a type or declare it in VAR_EXTERNAL.

where the docs say so

TAG-006 verified

A VAR_EXTERNAL declaration wins for its program but must agree with a stated type: (VAR_EXTERNAL Speed : BOOL against type: REAL is an error at the declaration). A tag with only an init: takes the declared type, and an init: that is not a value of it (TRUE for a REAL, 2.5 for a DINT) is an error naming the tag.

where the docs say so

TAG-007 verified

Programs that bind one untyped tag with different types are an error naming both tasks, and a type: on the tag settles it.

where the docs say so

TAG-008 verified

A program's own local with a tag's name shadows the tag in that program (the tag is neither read nor written there), and naut check warns "local X shadows the project tag X".

where the docs say so

TAG-009 verified

Function blocks and functions do not see project tags implicitly; a block reaches a tag only through its own VAR_EXTERNAL.

where the docs say so

TAG-010 verified

A file-level VAR_GLOBAL block in a library .st (a GVL) declares globals for every program; a global the manifest does not declare becomes a state tag starting at the zero of its type, a redeclaration must agree in type, and VAR_GLOBAL CONSTANT entries are constants rather than tags.

where the docs say so

TAG-011 verified

runtime.Options.Tags states each tag's role once (Input, Output, Setpoint, State with Init/Desc/Unit), composes with the flat Inputs/Outputs/Seed/Meta fields, and an Input is deliberately not seeded.

where the docs say so

TAG-012 verified

type: accepts every IEC elementary type, a project TYPE (UDT or enumeration) or an ARRAY[lo..hi] OF either, and a typed setpoint or state tag with no init: starts at the zero of its type.

where the docs say so

TAG-013 verified

type: and init: must agree: type: INT with init: 2.5 is a load error naming the tag and its type.

where the docs say so

TAG-014 verified

A type: naming something that is neither an IEC elementary type nor a project TYPE is refused, listing the types the project does declare.

where the docs say so

TAG-015 verified

A function-block type is refused as a tag type, since an instance is program state, not a tag; so is a type that holds one: an array of a standard or project block at any nesting, and a TYPE whose STRUCT has a block member. A manifest tag with no type: that a program declares as one of these is refused at load the same way.

where the docs say so · A standard block (type: TON) and a project-declared FUNCTION_BLOCK are both refused with '<name> is a function block', not the unknown-type message. Also asserted: ARRAY[1..2] OF TON, an array of a project FUNCTION_BLOCK, a nested and a two-dimensional array of blocks, a TYPE that is an array of TON, a STRUCT with a TON member, a STRUCT reaching one through an array member, and an array of that STRUCT are each refused with a message naming the block (never the unknown-type one), while arrays of INT and of arrays of REAL still load; the st test pins the exact messages, the scalar one unchanged. An untyped input, output, setpoint or state tag whose program's VAR_EXTERNAL declares it TON, a project block, an array (1-D or 2-D) of either, or a STRUCT with a TON member is refused naming the tag and the program; a GVL's block-typed global and a program's own VAR_GLOBAL no manifest entry names still load.

TAG-016 verified

GET /api/meta reports each tag's declared type beside its desc, plus an enumeration's members; program locals get the same entry under locals; a struct lists members only where an enumeration sits beneath; and the stream carries an enumerated value as its member name.

where the docs say so

  • pass TestMetaTagTypes in server
  • pass TestEnumTagsRenderByName in runtime
  • pass TestStreamCarriesEnumeratedValueByName in server

TAG-017 verified

An init: on an untyped tag is seeded as the type the programs declare for it (init: 0 on a DINT binds an integer; init: 65 on a REAL stays a REAL), and a tag no program declares seeds a number as a REAL.

where the docs say so

TAG-018 verified

A seeded integer tag is born to a Sparkplug host as an Int64 and a REAL as a Double.

where the docs say so · The manifest-side tests show an init: 0 on a DINT seeds an integer and init: 65 on a REAL stays REAL; TestNBIRTHBornTypesOfSeededIntegerAndRealTags decodes the node's real NBIRTH and checks Int64 and Double.

TAG-019 verified

A UDT-typed manifest tag seeds to the zero of the type by default, and for setpoint/state tags init: may be a (nested) mapping of member names to values; members the mapping omits stay zero.

where the docs say so

TAG-020 verified

TIME members seed from an ST literal, a bare duration (90s, 500ms) or whole milliseconds, and ARRAY members seed from a list element by element from the lower bound, leaving unnamed elements zero.

where the docs say so

TAG-021 verified

An unknown member name in a struct init: is a load error naming the tag and member path with a did-you-mean hint, and a struct-typed tag given a scalar init: is an error.

where the docs say so

TAG-022 verified

POST /api/tags accepts a dotted member path (to any depth) and read-modify-writes the whole struct, leaving other members unchanged.

where the docs say so

  • pass TestWriteMemberOfStateAndOutputRoots in server
  • pass TestSetPathScalarMember in runtime
  • pass TestSetPathNestedMember in runtime

TAG-023 verified

An object value on POST /api/tags merges onto the tag: members it does not name keep their current values (unlike init:, which zero-fills).

where the docs say so

  • pass TestWriteStructMapMerges in server
  • pass TestSetPathMapMergesAndKeepsOtherMembers in runtime

TAG-024 verified

A member write never creates anything: an unknown tag, a misspelled member (with a did-you-mean) or a member path into a scalar tag is a 400 carrying the reason, not a new top-level tag.

where the docs say so

  • pass TestWriteMemberErrorsAre400WithReason in server (7 of 7 passed)
  • pass TestSetPathUnknownMemberSuggests in runtime
  • pass TestSetPathOnNonStructRootCreatesNoJunkTag in runtime
  • pass TestSetPathUnknownRoot in runtime

TAG-025 verified

A member write keeps the leaf's type (a number lands as REAL or as an integer according to the member) and a type mismatch is an error, not a silent retype.

where the docs say so

  • pass TestSetPathTypedMembers in runtime
  • pass TestSetPathTypeMismatch in runtime
  • pass TestSetPathWholeNumberOnRealMemberStaysReal in runtime

TAG-026 verified

An enumerated member takes its member name (any case, Mode#Run qualified, or its integer), and a name that is no member is a 400 listing the members.

where the docs say so

  • pass TestWriteEnumMemberByPath in server
  • pass TestWriteEnumNonMemberIs400ListingMembers in server
  • pass TestSetPathEnumMemberByName in runtime
  • pass TestSetPathEnumNonMemberIsAnError in runtime

TAG-027 verified

An array element is addressed as [n] counted from the array's declared lower bound, an index outside the bounds is a 400 giving them, and a list value merges onto an array element by element.

where the docs say so

  • pass TestWriteEnumMemberByPath in server
  • pass TestWriteEnumNonMemberIs400ListingMembers in server
  • pass TestSetPathEnumMemberByName in runtime
  • pass TestSetPathStructWriteCoercesEnumsDeep in runtime

TAG-028 verified

The role rule is the root tag's: a member of a driver-owned input is refused, while setpoint, state and output roots are writable.

where the docs say so

  • pass TestWriteMemberOfInputRootRefused in server
  • pass TestWriteMemberOfStateAndOutputRoots in server
  • pass TestMemberWritesToSetpointRootAndInitlessStructOutput in server

TAG-029 verified

GET /api/meta reports "memberWrites": true so an HMI can detect member-path support.

where the docs say so

  • pass TestMetaAdvertisesMemberWrites in server

TAG-030 partly verified

Dotted and indexed paths also address tags in a test's given:/expect:, in a force, in the built-in dashboard's tag table (each leaf of a writable struct tag is editable), and through the HMI kit's rt.writeTag.

where the docs say so · Given/expect, force and the built-in dashboard's editable struct leaves are asserted. The HMI kit's rt.writeTag (TypeScript, in hmi/) is not exercised by any Go or naut test.

  • pass TestGivenAndExpectAddressFields in acceptance
  • pass TestGivenEnumMemberByPath in acceptance
  • pass TestForceStructMember in runtime
  • pass TestForceEnumMemberByPath in server
  • pass TestDashboardWritableStructLeavesAreEditableByDottedPath in server

TAG-031 verified

Runtime.OnScan observers fire once per main-task Scan(), after the program has run, see the store as that scan left it, run in registration order, and work under a virtual clock.

where the docs say so

  • pass TestOnScanFiresWithPostProgramValues in runtime
  • pass TestOnScanMultipleObserversOrder in runtime
  • pass TestOnScanUnderVirtualClock in runtime

TAG-032 verified

A panic in an OnScan observer is recovered and logged without taking the scan down, and the returned cancel func unregisters (idempotently).

where the docs say so

  • pass TestOnScanPanicIsRecoveredAndScanContinues in runtime
  • pass TestOnScanCancelStopsFiringAndIsIdempotent in runtime
  • pass TestOnScanPanicIsLogged in runtime

TAG-033 verified

Tags.ReadPath reads a dotted member path (scalar leaf or struct sub-tree) from inside an OnScan callback without deadlocking.

where the docs say so

  • pass TestOnScanObserverCanReadPathWithoutDeadlock in runtime
  • pass TestReadPathScalarLeaf in runtime
  • pass TestReadPathStructSubTree in runtime
  • pass TestReadPathNestedLeaf in runtime
  • pass TestOnScanObserverCanReadDottedPathsWithoutDeadlock in runtime

TAG-034 verified

server.hmi serves a built static SPA at / in place of the dashboard, falling back to the bundle's index.html for unmatched non-/api paths, with the built-in dashboard moved to /_nautilus/ and /api/* untouched.

where the docs say so · The static file, SPA fallback, /api/state still answering JSON, and the dashboard and its assets under /_nautilus/ are asserted. An unmatched /api path (GET /api/nope, or a GET on a POST-only route) is a JSON 404, with or without server.hmi, and non-/api paths such as /apis still fall back to index.html (#301).

  • pass TestHMIServesStaticFile in server
  • pass TestHMISPAFallback in server
  • pass TestHMIDoesNotShadowAPI in server
  • pass TestHMIMovesBuiltinDashboardToNautilusPrefix in server
  • pass TestHMIBuiltinAssetsMoveWithDashboard in server
  • pass TestUnmatchedAPIPathIs404 in server (3 of 3 passed)
  • pass TestHMISPAFallbackSurvivesAPI404 in server

TAG-035 verified

The server.hmi path is relative to the project and must resolve inside it.

where the docs say so

TAG-036 verified

naut build embeds the HMI build output in the binary like any project file, and the built binary serves it.

where the docs say so

  • pass TestRunBuildEmbedsHMIWhenPresent in cmd/naut
  • pass TestBuildEmbedsHMIAndBuiltBinaryServesIt in cmd/naut

TAG-037 verified

When the server.hmi directory is not built yet, neither naut run nor naut build fails; both warn and fall back to the built-in dashboard.

where the docs say so

TAG-038 verified

naut build prints a warning when the embedded project comes out over about 50 MB.

where the docs say so

  • pass TestBuildWarnsWhenEmbeddedProjectIsOver50MB in cmd/naut

TAG-039 verified

A driver input must be listed in the allowlist (the manifest tag with role: input); a driver cannot create arbitrary tags by delivering extra names.

where the docs say so

  • pass TestDriverCannotCreateUnlistedTags in runtime

TAG-040 verified

Every stored value carries the generation at which it last changed, and writing a value equal to the one already stored does not advance the counter.

where the docs say so

  • pass TestGenerationAdvancesOnlyOnChange in runtime
  • pass TestGenerationStructWrites in runtime
  • pass TestPerTagGenerationUnchangedBySameValueRewrite in runtime

TAG-041 verified

Tags.SnapshotInto refills the caller's map with Sample{Value, Gen} instead of allocating a fresh copy, and ChangedSince(gen, dst) appends exactly the tags that moved and returns the generation to ask with next.

where the docs say so

  • pass TestSnapshotInto in runtime
  • pass TestChangedSinceReportsOnlyWhatMoved in runtime
  • pass TestChangedSinceAccumulatesAcrossTicks in runtime
  • pass TestChangedSinceReusesTheCallersBuffer in runtime
  • pass TestChangedSinceZeroIsEverything in runtime
  • pass TestSnapshotIntoRefillsTheCallersMapWithoutAllocating in runtime

TAG-042 verified

WriteOutputs is called on the first scan, on any scan where an output differs from the last value handed over, after a failed write and after a redundancy takeover, and skipped otherwise; Options.AlwaysWriteOutputs restores a per-scan call.

where the docs say so

  • pass TestHostWriteOutputsSkippedWhenUnchanged in runtime
  • pass TestAlwaysWriteOutputs in runtime
  • pass TestWriteOutputsTriggersFirstScanFailureAndTakeover in runtime

TAG-043 verified

A driver implementing io.BatchReader is handed the same map each scan via ReadInputsInto, and the runtime falls back to ReadInputs when it is absent.

where the docs say so

  • pass TestBatchReaderGetsTheSameMapEachScanAndPlainFallsBack in runtime

TAG-044 verified

Change detection is conservative: two struct values are equal only if they share the same StructDef, and a function-block instance is never equal to anything; it can report a change that did not happen but never hide one.

where the docs say so

  • pass TestGenerationStructWrites in runtime
  • pass TestFunctionBlockInstanceNeverEqual in runtime

TAG-045 verified

Quality travels beside the tags: the non-good entries appear in /api/state and every stream frame as a quality map, an absent name means good, and a driver-bound input whose last read failed is stale.

where the docs say so

  • pass TestQualityDerivesStaleFromFailedReads in runtime
  • pass TestQualityDropsGoodEntries in runtime
  • pass TestQualityFromDriverWins in runtime
  • pass TestFrameCarriesQuality in server
  • pass TestFrameOmitsQualityWhenEverythingIsGood in server
  • pass TestStreamFrameCarriesQuality in server