Skip to content

Audit record format — specification

Format 1 · status: published, open. Revision 1.2, 2026-11-04: §3a lets a text record declare the quoted-scalar grammar for itself, with textEncoding: QUOTED_SCALARS as the first line after eventLogRecord:. Additive — an unmarked file is unchanged, byte for byte. A reader without 1.2 ignores the line as an unknown field under §2 and reads the record as legacy: quoted values show their quote marks and escapes, but entry boundaries hold, because a writer spells a " inside a quoted value as \x22, which a legacy scanner does not see as a quote. Revision 1.1, 2026-09-21: §1a adds an optional stream-end marker. Additive — a file without one is unchanged, and a reader without §1a tolerates one as unknown fields under §2. Revised the same day, before release, after review: the "stopped mid-write" state is withdrawn as unsound, a marker counts per segment, and a marker is recognised by the record's whole contents rather than by a key it mentions. §1a says what each correction cost. This page is the normative description of the record format the analyser reads. It exists so that anything that can describe a run — a Fluxtion processor, a Mongoose server, a workflow engine, a translator over someone else's trace — can emit records the analyser understands, and know what the analyser will do with them. The Log format page is the friendly tour; this one is the contract.

The format is open by decision (spec source adapters, D-A6): the layout is not the thing worth protecting, and an adapter ecosystem only exists if emitting it is safe and obvious. What is held is the name. The analyser is the reference implementation, and the conformance fixtures are what "the analyser understands it" means, pinned.

The key words MUST, SHOULD and MAY are used as in RFC 2119.

1. Container and framing

  • A text container is a sequence of records separated by lines consisting of ---. A record is everything between two separators, from its first non-blank line. Blank text before the first separator, after the last, or between two separators is skipped; non-blank text anywhere is a record — there is no banner or comment-block position in Format 1, and a file that starts with one gets a PARSE_ERROR record for it (§2). The built-in reader handles this container.
  • A reader plugin (log-source plugins) hands records over one at a time as canonical record text — the same shape a text container holds between separators. For a container that is not text (a database, a columnar file, an engine's own trace store) that text is a rendering the reader produces. Everything above the reader — the detail view, the free-text filter, read, report quoting — works on that text, so it MUST be complete: a record with no text form is not a record.
  • Records are consumed in container order. The analyser never re-sorts.
  • A reader plugin that hands over an unterminated final item MUST apply §1a's termination rule itself, because the analyser cannot see where a plugin's items ended. A plugin over a text container should withhold such an item exactly as the built-in reader does.

1a. Saying the file is whole (Format 1.1, additive)

A container MAY end with a stream-end marker: a record carrying streamEnd and, when it can, streamEndRecords.

---
eventLogRecord:
  streamEnd: normal        # normal | stopping
  streamEndRecords: 25     # records written since the previous marker, or since the start
---

A marker MUST be followed by a --- separator, and a reader MUST NOT treat an unterminated final record as a marker. This is not tidiness. At the byte level a marker its writer has finished and one it is halfway through writing are the same bytes: streamEndRecords: 1 is a complete marker for a one-record run and also the first character of 12. Measured on a reader that did not require termination, a marker caught mid-write after twelve records reported "this log declares 1 record and 12 were read - the marker is wrong", a fabricated verdict about a file that was simply still being written. The same ambiguity made a live reader and a fresh read of identical bytes disagree for ever about whether the file was complete.

The terminator is what makes the claim atomic: until it is there, nothing has been claimed. An unterminated final record that does not look like a marker is an ordinary record in a static read and a pending record in a live read. One that DOES look like a marker is neither: a reader SHOULD hold it back and report unterminated marker, rather than showing an empty row nothing explains.

The price, stated plainly. A file whose writer has genuinely finished reads as unknown until that writer emits one more separator. That is the cost of removing the ambiguity, and it is paid by producers rather than readers. It is one line in a producer: write the separator after the marker as well as between records. A trailing separator has always been legal under §1, and every existing reader accepts one, including released 1.17.0.

A marker record carries nothing else but, optionally, its own logTime. A record that also names an event, carries nodeLogs, or holds any other key is a record, whatever it says about streamEnd — and a reader MUST index and count it. This is not pedantry about shape: audit records carry a producer's own toString output, which may contain any text at all, including a line that reads exactly like this key. A reader that recognises the marker by looking for the key will delete such a record from its index and then report the file as short. Recognise the marker by what the record contains in full, never by what it mentions.

The recognition rule, precisely. Take the record's text, split it into lines, and strip each line of surrounding whitespace and a leading byte-order mark. Whitespace here is space, tab, carriage return and line feed only - not U+00A0 or any other Unicode space, which are content. A line that begins with a non-breaking space is therefore not a key, and its record is not a marker. Ignore lines that are empty, that begin with # (the §2 header comment), or that are exactly eventLogRecord:. The record is a marker if and only if every remaining line is one of:

line rule
streamEnd: <value> REQUIRED, exactly once, and <value> after unquoting MUST be non-empty
streamEndRecords: <value> OPTIONAL, at most once
logTime: <value> OPTIONAL

A line matches one of these when it begins with the key, then a colon, then optional whitespace. Note that this differs from YAML, where streamEnd:normal with no space is a plain scalar rather than a key: Format 1 accepts it as a key, here and in §2 alike, because §2's reader has always split on the first colon of an identifier. A reader that requires the space will disagree with the analyser on a record no producer is likely to write, and it should not. A reader built on a YAML library will disagree here, because that library reads streamEnd:normal as a plain scalar rather than a key: such a reader sees no marker and reports unknown, which is the conservative answer and therefore safe. A reader that wants to agree exactly should split the line itself rather than delegate it.

Any other line, a second streamEnd, a second streamEndRecords, or an empty streamEnd value makes the record an ordinary record. <value> is read to end of line, and then:

  • a value that begins with a single or double quote runs to its matching quote, and anything after the closing quote is discarded — so "25" # declared is the value 25, and a # inside the quotes is data, not a comment;
  • an opening quote with no matching quote later in the line is not special: it is part of the value, which then follows the rule below. So streamEnd: "normal is the value "normal — a non-empty value, and therefore a marker, since §1a tolerates any value it does not recognise. It is NOT an empty value and does not make the record ordinary;
  • otherwise an unquoted # begins a comment and the value is what precedes it.

A value is stripped of the whitespace defined above after it is extracted, quoted or not; a value that is empty after stripping is empty, and an empty streamEnd value makes the record an ordinary record.

A streamEndRecords value, after the stripping just described, is ASCII digits 0-9 with an optional leading + or -, and nothing else. This is stated exactly because "parseable as an integer" is not portable: Java's parser accepts any Unicode decimal digit, so streamEndRecords: 3 (fullwidth) and ٣ (Arabic-Indic) read as 3 and a file was reported complete where a stricter reader said unverified - the unsafe direction. Python's int() additionally accepts surrounding whitespace and 3_0. A value outside this rule, or absent, out of 64-bit range, or negative, means no count, and the reader reports unverified rather than guessing. Flow style ({streamEnd: normal}) is not a marker under this rule; a reader MAY accept it, and one that does not is conformant.

An unrecognised streamEnd value is tolerated: any non-empty value makes a marker. normal and stopping are the defined values; a reader MUST NOT treat a third value as a reason to reject the marker or the file. A reader MAY surface the value. Whether the distinction is worth surfacing at all is an open question in the design spec, and the analyser currently reads the value without showing it.

Two things this rule deliberately does not say. A PARSE_ERROR record (§2) counts towards its segment like any other record, because it occupies a position a producer wrote to. And nothing here applies to a set of rolled files: a marker vouches for the file that carries it, so a set of whole files is not a whole set, and a reader MUST NOT report one as complete. Nothing in Format 1 records how many files a set should hold, in what order, or under what identity.

That last one is deferred rather than refused, on the same terms as the mid-record limit above. Two routes would establish it: a manifest naming the set's members, or a marker naming its successor, so the files form a chain a reader can walk. Neither is specified here, and a reader MUST NOT infer a set's completeness from its members', from filenames, or from timestamps being contiguous. A reader SHOULD say what the members established and that the set's own completeness is unknown, so that a person looking at a shelf of whole-looking files does not supply the conclusion themselves.

It is physically a record and semantically a container fact, and it is a record only because §1 leaves no position for non-record text. A reader MUST NOT present it as a record: not in a record count, a table, a query result, a report, coverage, a series, or the time range. A reader that does present it is still conformant — it has merely failed to recognise an unknown field, which §2 permits — but it is not doing what this section asks.

A writer SHOULD emit the marker only when it believes it finished. A writer that cannot count its records MAY omit streamEndRecords; a reader MUST then treat the completeness claim as unverified rather than proven, because a marker that cannot say how much it wrote is not evidence.

A marker counts the records since the previous marker, or since the start of the container if it is the first. Two whole runs appended into one file — which is what a cumulative export produces across restarts — are therefore two segments, each checked against its own marker, and the file is complete.

Five states, and a reader SHOULD distinguish them:

the container the reader reports
every marker's count matches its segment, and a marker is last complete, and verified
a marker claims more records than its segment holds records missing, naming both numbers
a marker claims fewer records than precede it more records than declared — the marker is wrong, or it is not an end
a marker carries no readable count unverified: an end is claimed and nothing backs it
no marker, or records after the last one unknown whether complete
ends with a marker that has no closing --- unterminated marker — the claim is unfinished; the marker is not shown as a record

Precedence, when more than one row applies. When the final item is an unterminated marker, that row wins: the records before it are not "records after the last marker" for this purpose, and any run that already failed its count is still reported. Otherwise, records after the last marker make the FILE unknown: nothing vouches for the tail. But a run that already failed its own count has already proved it, and a reader MUST still report that run. The file's state is unknown; the failed run is named beside it. A reader that reported only unknown there would conceal a proven loss behind an honest one, which is the failure this section exists to prevent. This is the ordinary shape of a cumulative export read while its server is running again after a restart.

The last row is the point, and it is the common case. Silence MUST NOT be read as completeness. Every producer that predates this section, and every export the analyser has ever read, lands there and MUST keep loading exactly as before. What a reader owes is to say it does not know, not to guess.

What a reader cannot do, and the one condition under which it could. A text container cannot detect a writer that stopped in the middle of a record unless the writer has declared that it closes every record. §1 makes --- a separator: a whole file may end with its last record and no separator after it, and real producers do exactly that. The absence of a trailing separator therefore carries no information on its own, and a reader MUST NOT report such a file as truncated, damaged or stopped. An earlier draft of this section did, and it reported every export from the reference producer as damaged while every conformance fixture stayed green, because all of them happened to end with a separator. A run killed at any point carries no marker and is unknown, which is the honest answer.

The condition is worth naming rather than losing. A writer that terminates every record with ---, including the last, turns an unclosed tail back into a sound signal, because for that writer the absence is no longer ambiguous. Two ways a container could say so are open: a streamEnd marker earlier in the file already proves its writer emits markers, so a file with a marker and an unclosed tail after it is a mid-record stop; and a start-of-stream record could declare closed framing for the first run too. Neither is specified here, and a reader MUST NOT assume either. This paragraph exists so the capability is deferred on the record rather than quietly given up, and so that a writer contributing to a future revision knows which promise would buy it.

Two signals that look sound and are not, both measured. A tail that fails to parse is unreliable: a half-written record usually still parses, because a reader recognising any field at all treats the record as ordinary — the released reader read one with a single node log of four and no end time as OK. A byte count in the marker cannot help either, since a truncation that loses the tail loses the marker with it.

What the count can find is loss in the middle of a run, which no amount of tail inspection would have found.

A writer SHOULD omit logTime from the marker, and MUST NOT give it a time later than the last record's. This is the one place the marker can affect an older reader, and it is avoidable. Measured against the released 1.16.0 reader, which has no §1a support: a marker timed after the last record moved that reader's maxLogTime from 1001 to 1002, widening its time range and every axis drawn from it. The same file with the marker untimed — or reusing the last record's time — left the range untouched. An untimed record is already defined by §2 as kept but off the timeline, so this needs no new rule.

Compatibility, measured rather than asserted. A reader written before this section sees one extra record carrying unknown fields, which §2 already requires it to tolerate. Verified against released 1.16.0: a marked file loaded with 3 records, zero parse errors, every record OK, against 2 records for the same file unmarked. With the marker untimed, the two files differ only by that extra record. Nothing about Format 1 changes for a file that carries no marker.

2. The record

A record is an optional header line followed by an eventLogRecord: mapping.

---
#10:57:37.431 [marketMaker-DEMO] INFO  MAKER_DEMO        # header: time [thread] LEVEL logger — all optional
eventLogRecord:
  eventTime: -1                        # epoch millis of the driving event; -1 = not event-driven
  logTime: 1786355857430               # epoch millis — THE timeline
  groupingId: null                     # optional correlation id
  event: ExportFunctionAuditEvent      # event class or trigger type
  eventToString: public boolean com.acme.demo.VenueHedgeMonitor.orderVenueConnected(com.acme.demo.OrderVenueConnectedEvent)
  thread: marketMaker-DEMO
  nodeLogs:                            # ORDERED — see §4
    - hedgeConnectionMonitor: { status: CLOSED, hedgeQuantity: NaN}
    - bidMakerOrder: { orderStatus: noLiveOrder, isOrderClosed: true}
    - bidMakerOrder: { connected: true}
  endTime: 1786355857431
---
Field Type Rule
header #… text MAY be present. Lenient: #time [thread] LEVEL logger, every part optional. Supplies thread, logger and level when the record does not.
logTime integer SHOULD be present. The primary timeline; the analyser windows, slides and validates on it and nothing else. Units are the reader's declared time base (§6); the built-in reader's is epoch milliseconds UTC.
eventTime integer MAY be present. The driving event's time. -1 is a sentinel meaning not event-driven (a timer, a lifecycle step, an exported service call) and reads as absent. Never consulted for order.
endTime integer MAY be present. When the cycle finished. Absent means not recorded; an emitter constructing text from a source that did not record it MUST omit the field rather than write 0.
event string MAY be present. The event class name or trigger type.
eventToString string MAY be present. The event's toString(). When it is a Java method signature the record is an exported call and its dimension is the callback name (§5). Kept whole; never re-split on inner :.
groupingId string MAY be present; the literal null reads as absent.
thread string MAY be present. Wins over the header's thread when both are given.
nodeLogs list SHOULD be present. The cycle's contributions, §3–§4.
anything else — Ignored, never rejected. A newer producer MUST NOT break an older analyser; an emitter MAY add fields, and MUST NOT expect them to mean anything yet.

Every record is kept. A slice that cannot be parsed becomes a PARSE_ERROR record carrying its raw text; counts are preserved and nothing is dropped silently. A record with no logTime is a legitimate record: it is kept and readable, it is off the timeline (it does not move the log's start or end), and it orders nothing (§2.1). (Fixtures C01, C02, C03, C04, C05, C09.)

A value MUST NOT break its record. A value that can hold a line break — above all eventToString, which carries an event's own text, and so whatever an operator typed into an admin command's arguments — MUST be quoted or escaped, because a raw line break ends the line and whatever follows reads as if the producer had written it.

The analyser reads a record's structure by one rule, the same for finding a break and for reading fields:

  • A field is a key: line at the fields' indentation (the first field's), outside a quoted value and outside the node-log block.
  • The node-log block follows nodeLogs:. It holds every line more indented than the fields, and the - items at the fields' indentation. A line at the fields' indentation that is not an item ends it.
  • A line that begins inside a closed quoted value continues that value, wherever it is. It is never a field, never a new item and never the end of the block. A quote that never closes is text.

These shapes are impossible in a record written whole, so each makes it a broken record:

  • a line less indented than the fields;
  • outside the node-log block, a line more indented than the fields — unless it continues a block scalar (|, >) or nests under an unknown key with no value, which is ignored like any unknown field;
  • a non-blank line at the fields' indentation that is not a field;
  • a second eventLogRecord: line;
  • a field written twice.

(A bare --- line the framer did not split on is framing's subject, not a value's: the record key after it is the break, and the run-together records are reported as such.)

What a broken record keeps. It is kept and counted. Reading stops before the earliest structural break and before the FIRST occurrence of every repeated recognised field, even when the repeat follows another break. Neither copy is trusted: grouping IDs and thread names are also written unquoted, so a first copy can be payload text before eventToString, or when event text is disabled. This conservative rule can withhold a genuine first copy too.

The additional cutoff at eventToString, nodeLogs, or an unknown field remains: a broken record is never read past that field's own line. This also withholds later, never-repeated forged fields. It is not a guarantee that other producer-written strings are safe; the structurally indistinguishable-input limit below still applies. None of a broken record's node logs are read, and the analyser names the break by record and line.

Withheld is not absent. A broken record's node logs were recorded, and the analyser declined to read them. Coverage, the graph pairing, the records table and the CSV export say so — withheld, not read — and never that a node never wrote or that the record holds no node logs.

The one line break a producer writes on purpose. An exported service call's eventToString is the generator's description of the method: @Override, a line break, and the method's public …(…) signature. It is a constant in the generated processor, never runtime data. Exactly that shape is whole: the record's first event is ExportFunctionAuditEvent, eventToString is exactly @Override, and the next line is one signature at column 0. The value reads @Override, and AF-9 tracks reading the signature.

Not detectable. A forged field at the fields' own indentation, under a key the producer did not write for that record, followed by text shaped as an unknown field, leaves no structural trace. Only the producer quoting its values closes that gap.

(Fixtures C31, C32.)

2.1 Time order is a claim the analyser checks

logTime SHOULD be non-decreasing in container order. When it is not, the analyser reports it — kind OUT_OF_ORDER, with the first offending record — in the status bar, in context, and as a caveat on every time-anchored verb. It never repairs: a backwards timestamp is evidence of a clock step, a bad merge or a bad transport, and re-sorting would destroy it. An emitter that goes backwards is told, every time. (Fixture C06.)

3. nodeLogs — values and attribution

Each item is - instanceId: { key: value, key: value, … } — one component's contribution to this cycle. instanceId is the component's stable identity (in Fluxtion, the node's field name in the generated processor); it is the join key to a declared graph (§7), to source navigation, and to every instanceId.key series.

  • Values are not YAML. They are raw toString() output: inner commas, brackets, NaN, key=value runs. The analyser splits only on top-level separators, respecting (), [], {} and quotes, and never fails a record on a value. Only top-level numeric and boolean values are graphable; a number inside a toString() is text. (Fixture C08.)
  • Quote marks are the producer's characters. Under this grammar a " protects the separators inside it while the tokenizer scans, and that is all: nothing is decoded, a backslash is a character, "hello" keeps its quotes, and prefix "C:\" closes at its second quote mark. This is how every text log has always been read, and it does not change for any record that does not declare the quoted grammar itself (§3a). (Fixture C17.)
  • An entry is one run of writes, not one callback, and a key may repeat inside it. Fluxtion's writer opens a new - instanceId: {…} entry only when the writing node changes, or when invocation tracing starts an invocation (§8). A node that logs in two callbacks with no other node writing between them — @Initialise then @AfterEvent, say — therefore writes ONE entry with repeated keys: - pe: { cb: Initialise, n: 1, cb: AfterEvent, n: 2} (fluxtion-runtime 1.1.2). That is not legal YAML 1.2, which requires the keys of a mapping to be unique, so a strict YAML parser rejects it and a lenient one keeps one value; neither is how this format is read. The analyser keeps every pair, in order, and fails nothing. The record does not say where the first callback ended, so the analyser does not guess: it never splits an entry on a repeated key, because a node may log one key twice in one callback. What follows is that an entry count (the records table's nodeLogs column, the steps of a topology walk) counts entries, not callbacks, and that every value surface — series, read projections, markers, diffs — takes the last occurrence in the record, inside an entry as across entries, so a merged entry and the same writes in separate entries read alike. (Fixture C34.)

3a. The quoted-scalar grammar — declared, never inferred from content

A second grammar is selected by an explicit declaration, in one of two places: the reader that constructs record text from typed values declares it through the SPI, for every record it delivers; or a text record declares it for itself, in the one position described below. It adds one rule to §3: a double-quoted scalar is a string, whatever it spells. An instance id, key or value that is entirely "…" is decoded and read as text. The escapes are \\, \", \n, \r, \t, \0, \xXX (two hex digits) and \uXXXX (four hex digits; a high and a low surrogate written as two consecutive \u escapes combine into one character). Hex digits are ASCII, in either case. An escape that is none of these — an unknown letter, too few hex digits, half a surrogate pair — is kept verbatim and nothing fails; a backslash with nothing after it before the closing quote means the scalar is not one quoted scalar, and it is the raw text. While scanning, a backslash inside double quotes escapes the next character. Decoded values read as text: "42.0" is not a figure, "null" is not null, "true" is not a flag, and the commas, colons, braces and line breaks inside it split nothing. Anything not entirely one quoted scalar is the raw text it always was. (Fixture C16, through a reader that declares it.)

This is the one lossless spelling for text that would otherwise be syntax. The built-in binary reader declares it and MUST use the quoted form for any string the bare form would mis-split or mistype; a reviewer showed a logged "ok, price: 42.0" written bare manufacturing a numeric figure the producer never published, and a logged ' character deleting the entry after it. An adapter for another typed engine that constructs text does the same: declare, and quote.

Reserved keys. Under the declared grammar, a bare key beginning with @ belongs to the reader, never to the producer: a business key containing @ is quoted on the way out and decodes as an ordinary key. Two are defined, and neither becomes a business entry. @invoked: true marks a wire TRACE entry (tag 8, key 0) and becomes node metadata — traced on the node-log, beside the entries and never in them, so a business key that decodes to the same spelling keeps its own slot in every lookup, diff and field projection. It is evidence that its node ran, and nothing about nodes that did not log; it is not a declaration that every invocation was traced. The binary format carries no such declaration, so for a binary log absence stays may have run, and the text format's method-on-every-node heuristic applies only to records the text reader produced — applicability is carried on the record, from the reader's declaration, never taken from a property spelling. @unkeyed: value carries a wire entry that had no key and was not a TRACE, kept as an entry with no key — unnamed evidence, distinct from every named property including one named null, shown beside the node and outside every named-figure reduction: the scorer, series, the graph menu and field projections address names, and a keyless value has none. The legacy grammar has no reserved keys.

Both markers are the reader's display vocabulary, and every consumer that shows or re-reads a record works from the parsed model, never from that spelling. The consumers pinned by test are: the read verb and the report table assembly (the same call, the same parsed record — a table never re-parses the record's text under the other grammar, ReportBinaryEvidenceTest); the record detail's exact-key click (the tokenizer records the complete key span with its parsed entry; neither a partial key nor text inside a value is an exact target, DetailPanelExactClickTest). A click on @unkeyed: 42 does not resolve to unkeyed as an exact key; the node fallback may offer that genuine named property alongside the node's other named keys. Multiline items have no exact-click spans and retain this fallback. The logical view, step status and both PDF evidence assembly paths use the same evidence formatter: keyless entries print as bare @unkeyed, while named keys needing quoting, including the business name "@unkeyed", are quoted and escaped (EvidenceIdentityTest). The topology graph menu, series, diff and the scorer (rounds 8 and 9).

Encoding is never inferred from content. Logged content MUST NOT select or change it. The analyser never looks at a value, a quote mark or a backslash and guesses which grammar wrote it: the same bytes cannot say whether a quote mark was the producer's data or encoding syntax. A reader's declaration lives where the text cannot reach it, and it wins: a reader that declares the grammar applies it to every record, whatever the record says.

A text record's own declaration. Under a reader that declares nothing (the built-in text reader, or any reader whose textEncoding() is the default), a record MAY declare the grammar for itself with one line in its eventLogRecord mapping, at the fields' indentation:

---
eventLogRecord:
    textEncoding: QUOTED_SCALARS
    eventTime: 1786355857000
    logTime: 1786355857001
    event: MemberEvent
    eventToString: "MemberEvent{id=7, note: \x22vip\x22}"
    thread: main
    nodeLogs:
        - gymAccess: { reason: "membership not active, balance due 30.0", granted: false}
    endTime: 1786355857002
---

This is an explicit producer declaration, not inference, and it is recognised by position, never by mention — the rule §1a applies to the stream-end marker. It is the declaration if and only if it is the first non-empty line after the record's eventLogRecord: key (lines stripped of space, tab, CR, LF and byte-order marks exactly as §1a strips them; a comment line there disqualifies it), it is a field by §2's structural rule (at the fields' indentation, outside any quoted value), and the record's structure is not broken before it. Anywhere else — after endTime, after any other field, inside a multi-line eventToString, inside a node value, nested under another key — the same spelling is content: an unknown field, ignored like any other (§2), and the record stays legacy. That position is the fix for the reason an earlier draft was withdrawn: it put a nodeLogsEncoding: quoted scalar anywhere in the record, a reviewer logged a multiline legacy value whose middle line was that scalar, and the parser promoted value data into a control field and deleted the entry after it. A value cannot reach the first line of the mapping: every value follows its own key, and the record key is the line before.

Under a record's declaration:

  • the record's nodeLogs read with this grammar, exactly as under a reader's declaration — except that there are no reserved keys: the text runtime has no wire TRACE, so a bare @invoked is the producer's key and stays an entry;
  • the string header scalars — event, eventType, eventToString, thread, groupingId — that are entirely one quoted scalar decode the same way, and are text whatever they spell: a quoted "null" grouping id is the string null, while a bare null is still absent. (Under a reader's declaration header scalars stay raw, as before.) A producer SHOULD write " inside a quoted scalar as \x22, so a legacy scanner, which knows no escapes, still sees where every value ends; \" is accepted too, but an analyser older than revision 1.2 would end the value at it;
  • the record is still a text-runtime record: the method-on-every-node tracing heuristic (§8) and the trace-run reading of read keep applying to it;
  • the declaration is metadata: never a node entry, a key, a series or a header field, and it moves no record's offset or index.

The declaration is evaluated per record. A file — or a Follow stream — may hold unmarked records followed by marked ones (a producer upgraded mid-capture, a merged export), and each reads by its own declaration. A record without the line is read exactly as before: the legacy grammar, byte for byte, quotes kept, nothing decoded. Only the value QUOTED_SCALARS is honoured; any other value leaves the record legacy (nothing fails) and the analyser names it once for the log, with the first such record and the count, as the producer finding UNKNOWN_TEXT_ENCODING.

Export. A YAML export of a text log copies each record's own text, the declaration included, so it re-opens as the same records, marked and unmarked alike. A store whose reader declares the grammar is still refused a YAML export: its text carries no declaration. (Fixture C17 pins the round-5 record, "hello" keeping its quotes, and the multiline record — all unmarked. Fixture C35 pins a mixed file: an unmarked record, a marked one, the spelling after endTime and inside a multi-line eventToString, an unknown value, and a marked record with a bare @invoked.)

4. Order is a claim, and the reader declares it (D-A1a)

The nodeLogs list is ordered. What that order means depends on the reader's declared ordering capability (§6):

ordering position in nodeLogs means the analyser will
TOTAL the real execution sequence within the cycle — safe to read as causality step through it as "step n / m", paint dispatch-order badges, escalate routes along it
PARTIAL the order the entries arrived in; the source could not decide a sequence not paint ordinal badges, say "logged n / m" instead of "step", carry a standing arrival order, not dispatch order warning in the Topology status, and put orderMeaningful: false with its caveat in the topology echo

A Fluxtion processor's order is derived by the AOT compiler and is dispatch order: the built-in reader is TOTAL. An adapter over a concurrent engine — parallel super-steps, concurrent activities, overlapping spans — MUST declare PARTIAL rather than invent a sort key. The spike found the two cases indistinguishable on screen without the declaration; with it, the tool says which it is showing. (Fixture C10.)

Not in Format 1: a per-cycle concurrency marker, so that a mostly-sequential engine can be honest about the cycles that were not without disclaiming every record. D-A1a specifies it; the format does not yet carry it, and this page will say so until it does (see §8).

5. Exported calls and dimensions

When eventToString is a Java method signature — public boolean com.acme.X.method(com.acme.Arg) — the record is an exported service call: something outside the processor invoked one of its exported functions. Its dimension (the value the event filter and grouping use) is the callback name (method), and the declaring type is captured for source navigation. Otherwise the dimension is the event value. eventTime is -1 on such records because no event drove them. (Fixture C13.)

6. What a reader declares

A reader plugin (SPI) declares, and the analyser degrades loudly where the answer is no rather than assuming:

declaration meaning
timeBase mandatory: epoch unit (millis/micros/nanos), zone, and source (wallClock/monotonic/injected). Declared, never sniffed.
capabilities.follow the source can be tailed
capabilities.byteAnchors records have real byte offsets in a file (text containers only); otherwise anchors are by record index
capabilities.randomAccess records can be re-read by position
capabilities.ordering TOTAL or PARTIAL, §4. The pre-M34 three-argument form defaults to TOTAL, which is true of every container that existed before the claim did.
graph(source) MAY return the source's own graph, §7. Empty is an answer.

7. A graph, and where it came from (D-A2, D-A4)

A source MAY supply the structure its records refer to, in the analyser's own vocabulary: nodes {id, label, className, kind} and edges {id, source, target}, with kind one of NODE, EVENT, EVENT_HANDLER, EXPORT_SERVICE. It MUST say whether the graph is DECLARED (the source states its own structure — a compiler emitted it, a registry holds it) or INFERRED (reconstructed from what ran). A graph without a provenance cannot be constructed.

The distinction is load-bearing: coverage is declared minus observed. Against an inferred graph the subtraction is empty by construction — the answer is always 100% — so the analyser refuses to compute it and says why, and the Topology view states that an absence of "did not run" nodes proves nothing there. Node ids MUST match nodeLogs instance ids, because that is the join. An edge to an undeclared node is dropped. A graph the user opened by hand always wins over one the source supplied; the view always says which it is showing.

Engine scaffolding — pseudo-nodes, schedulers, the engine's own plumbing — MUST NOT be declared as authored nodes, or it will count as never ran. The analyser hides Fluxtion's own scaffolding by class name; a foreign adapter declines to emit its equivalent.

8. What absence means (UP-FLX-11)

A component that does not appear in a record either did not run or ran and logged nothing, and the record alone cannot say which. Format 1 has no field for it. The analyser infers a traced record — one where every entry carries a method key, which only Fluxtion's invocation tracing adds — and only then treats absence as did not run; otherwise it says may have run, never did not. A single component logging a business key called method MUST NOT make a sparse record look complete, and does not. (Fixture C12.)

Tracing writes FIRST in each entry, in a fixed order: thread (when thread names are printed), then method — or forkedExecution then asyncMethod for a forked node — and from Fluxtion runtime 1.1.1 annotation, the simple name of the annotation that dispatched the callback: - quoteDEMO: { thread: main, method: onTrigger, annotation: OnTrigger}. That leading run is framework data, not the component's own, and the rule is positional: a key after the run is the component's own even when it is spelled thread, method or annotation, an annotation whose value is not a capitalised simple name is the component's own, and an entry that does not start with method (after an optional thread) or the forked pair has no framework keys — a lone thread or annotation is a business key. An entry holding only the run logged no business value. (Fixture C33.)

Tracing marks the dispatch callbacks only — @OnEventHandler and @OnTrigger invocations. Lifecycle callbacks (@Initialise, @Start, @Stop, @TearDown, …), @AfterTrigger and @AfterEvent never write a trace run, and @AfterEvent runs on every node at the end of every handled event and lifecycle phase. So did not run means no dispatch callback ran; it says nothing about those callbacks. And a traced build in which any node logs from one of them writes entries without method, so its records read as untraced and absence there is may have run — the safe direction, until the record can declare its regime (UP-FLX-11). (Fixture C34.)

One case stays ambiguous: a component of a runtime before 1.1.1 that logged its own annotation as its first key, with a capitalised simple-name value (annotation: Approved), reads as trace. The record cannot say which; an emitter SHOULD NOT log a key called annotation first.

A declared marker is an open upstream ask (UP-FLX-11). Until it lands, an emitter that traces every invocation SHOULD carry method on every entry so the inference holds; one that does not MUST NOT fake it.

Open in Format 1 — specified elsewhere, not yet carried by the record, and stated here so no emitter fills the gap with an invention:

  1. the per-cycle concurrency marker (§4, D-A1a);
  2. the traced-regime marker (§8, UP-FLX-11).

Conformance

The fixture set lives at src/test/resources/conformance/ — one file per pinned semantic, with a README table saying what each pins — and FormatConformanceTest is what passing means. Every fixture runs twice: through the built-in text reader (the reference implementation) and through the plugin SPI over a reader that hands the same records over unchanged. The two MUST agree record for record — which is the guarantee to an adapter author: emit these records and you get exactly what the native log gets. They must also agree on what the analyser says about the producer — an empty file, damage, a document with no record key — kind for kind and in order, for every fixture.

fixture pins
C01 minimal logTime + one entry is a whole record; it is already graphable
C02 unknown fields ignored, never rejected — forward tolerance
C03 header optional, lenient; the scalar thread wins
C04 times logTime is the timeline; eventTime: -1 is absent; endTime optional; eventTime never orders
C05 untimed kept, off the timeline, orders nothing
C06 out of order reported with its first record; never re-sorted
C07 duplicate instanceId every occurrence kept; last wins; one point per record
C08 lenient values only top-level separators split; nothing fails; NaN detected
C18 stream end the marker is a container fact, not a record: both paths report 2 records for a file holding 2 and a marker; its logTime does not extend the time range; no marker means unknown, never complete
C09 garbage a PARSE_ERROR record with its text; neighbours untouched; count preserved
C10 ordering claim TOTAL/PARTIAL is the reader's and reaches the index; the old constructor means TOTAL
C11 attribution the core attributes by position and never merges — broadcast makes duplicates; a component-less key is not even expressible
C12 traced regime absence is did not run only when every entry is traced; one method key proves nothing; a business invoked: true proves nothing in either spelling — the binary reader's trace marker (@invoked, §3a) is provenance that its node ran, never a completeness declaration
C13 exported call dimension is the callback; declaring type captured; eventTime absent
C14 synthesised text text an adapter constructs (no trailing newline, a leading ---, CRLF) reads exactly as sliced file text
C16 quoted scalars through a reader that declares QUOTED_SCALARS, entirely "…" is a string whatever it spells; escapes decode; its insides split nothing; the same bytes through the text reader are legacy
C17 legacy quotes text is legacy, byte for byte: quotes are data, a backslash is a character, the figure after prefix "C:\" is still a figure, and a value line spelled like a field is value data
C25 marker declaring zero an empty log is a finding, never a state: complete, and still "No records in this file yet."
C26 two empty segments two markers each claiming zero: complete, 0 declared, the same finding
C27 whitespace only blank lines are no records: unknown, the finding
C28 zero bytes also the empty export: unknown, the finding
C29 no record key completeness is not integrity: a headerless run-together line under a marker declaring 3 reads complete, 3 of 3, and record 2 is still named
C31 broken value a line break in an unquoted value forges nothing: two admin command records whose arguments carry nodeLogs: / eventLogRecord: and event: lines keep their dispatched event, read no node logs and are named; the whole records around them read as before
C32 record structure one structural rule: a payload indented deeper than the fields breaks the record and forges nothing; a closed quoted node-log value's continuation is never the event or a new node; the generator's exported-service record (@Override and one signature at column 0) is whole — on the built-in path, the legacy SPI path and a reader declaring the typed grammar
C30 per-node level a control record setting a node to WARN annotates that node on both paths; it stays uncovered
C33 trace annotation annotation, written after method by Fluxtion 1.1.1 tracing, is framework data like thread and method — by position, in the leading trace run only; a lone annotation, or one after the run, is a business key and makes nothing look traced
C34 merged entry one entry is one run of writes by one node, not one callback: repeated keys are every pair in order, never an error or de-duplicated; the entry count counts entries; the last occurrence in the record is the value, so a merged entry reads as separated ones; untraced @AfterTrigger/@AfterEvent entries keep a record out of the traced regime
C35 record text encoding a text record declares the quoted grammar itself only as the first line of its mapping: that record decodes (values and header scalars, "123" and "true" stay text, \0 \xXX \uXXXX and surrogate pairs decode, malformed escapes stay verbatim, no reserved keys); unmarked records, the spelling after endTime or inside a value, and an unknown value read legacy, the last named once; a reader's declaration still covers every record
C15 graph provenance a SourceGraph cannot exist without DECLARED/INFERRED; INFERRED forbids coverage; an opened graph outranks a supplied one; dangling edges dropped

To check an emitter: write its records to a file, open it in the analyser (or run the fixture through SpiLogStore with your reader), and compare against the table. To propose a fixture, add the file and the assertion — a fixture nobody asserts on is a promise nobody keeps.