blob: cdfd04fc3e554113c8f6ff461f58a2d2112ab3b3 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
// Package sessionflow defines how a conversation's structure is written down:
// an append-only chain of immutable rounds, one file each, extension .sf.
//
// Session Flow is the structure - how the conversation went. The detail of what
// was in it is Session Data, and a node here references it rather than carrying
// it.
//
// A round is not a snapshot. It carries only what changed, and the conversation
// is the FOLD of every round from the first to the latest:
//
// state₀ = empty
// stateₙ = apply(stateₙ₋₁, roundₙ)
//
// Each round is written once, made read-only, and never revised. Later evidence
// - a tool result that arrives after its call, a child transcript that appears
// after its spawn - produces a new revision of an entity in a LATER round, never
// an edit to an earlier one. That is what lets a round be digested, archived, or
// shipped the instant it is written.
//
// Rounds are linked by digest, not by filename: round N names the digest of
// round N-1. A holder of round 3 that has not seen round 2 can store it but
// cannot apply it.
package sessionflow
import (
"encoding/json"
"fmt"
)
// Schema is the round format version. Every round in a chain carries it, and a
// change of interpretation starts a new chain generation rather than continuing
// silently.
const Schema = "sf/1"
// FrameType discriminates the lines of a round file.
type FrameType string
const (
FrameHeader FrameType = "header"
FrameNode FrameType = "node"
FrameRelation FrameType = "relation"
FrameUnresolved FrameType = "unresolved"
FrameCommit FrameType = "commit"
)
// Header is the first line of a round.
//
// It deliberately carries no wall-clock time: a round's bytes must be
// reproducible from its inputs, so that re-deriving it from the same landed
// range, the same previous digest and the same parser version yields the same
// digest. When it was produced belongs outside the digest, in chain state.
type Header struct {
T FrameType `json:"t"`
Schema string `json:"schema"`
Conversation string `json:"conversation"`
Session string `json:"session"`
// Round counts from 1. Previous is the digest of round N-1, and is empty
// only for round 1 - it is a semantic dependency, not audit metadata.
Round uint64 `json:"round"`
Previous string `json:"previous,omitempty"`
// The landed sequence range this round consumed. FromSeq is inclusive.
FromSeq uint64 `json:"from_seq"`
ThroughSeq uint64 `json:"through_seq"`
// InputDigest binds the round to the landed evidence it read. It is chained
// rather than recomputed: H(previous_input_digest || digests of newly landed
// files), so producing it stays proportional to new data, not to history.
InputDigest string `json:"input_digest"`
// Parser and Policy are the interpretation versions. A change to either that
// alters meaning starts a new chain generation.
Parser string `json:"parser"`
Policy string `json:"policy"`
// FromTime and ThroughTime are the earliest and the latest record time
// among the landed files this round consumed, as the runtime wrote them,
// in UTC. They are evidence, not the parser's clock, so the same input
// reproduces them and they sit inside the digest. Absent when no record
// in the window carries a time. The session node carries the same pair
// for the whole conversation so far.
FromTime string `json:"from_time,omitempty"`
ThroughTime string `json:"through_time,omitempty"`
// SessionFromTime and SessionThroughTime are the session node's own
// pair as of this round: when the session began, and its last activity
// so far. They repeat what the session node carries so that a reader of
// the header alone, such as a receiver looking at a record's attributes,
// learns them without folding the round.
SessionFromTime string `json:"session_from_time,omitempty"`
SessionThroughTime string `json:"session_through_time,omitempty"`
// Title and the counts are what a list of conversations shows, as of
// this round: the session's title and the fold's talks, steps, streams,
// segments and open unresolved references. A receiver that lists
// conversations reads them off the header, or off the attributes a
// sender copies from it, and never folds.
Title string `json:"title,omitempty"`
Talks int `json:"talks"`
Steps int `json:"steps"`
Streams int `json:"streams"`
Segments int `json:"segments"`
Unresolved int `json:"unresolved"`
}
// Ref points at one landed record, and optionally one content block within it.
//
// Session scope comes from the header, so a reference carries neither a session
// id nor a digest. Seq selects the landed file and Row the line within it.
type Ref struct {
Seq uint64 `json:"seq"`
Row uint64 `json:"row"`
Block *int `json:"block,omitempty"`
}
// Entity is what every non-header, non-commit frame has in common.
//
// ID must be derived from stable evidence - session, stream, kind and the
// runtime's own identity for the thing - never from a position. A positional id
// shifts when late evidence arrives, and a shifted id cannot supersede its own
// earlier revision.
//
// Revision is the round number that produced this version. Deriving it from
// chain position rather than counting per entity is what keeps a round
// reproducible without replaying the chain.
type Entity struct {
T FrameType `json:"t"`
ID string `json:"id"`
Revision uint64 `json:"revision"`
// Tombstone removes an entity from the materialised result while leaving it
// in history. Absence from a later round means unchanged, never deleted, so
// removal has to be explicit.
Tombstone bool `json:"tombstone,omitempty"`
}
// Node is one element of the conversation structure.
type Node struct {
Entity
Kind string `json:"kind"`
Parent string `json:"parent,omitempty"`
Stream string `json:"stream,omitempty"`
Ref *Ref `json:"ref,omitempty"`
Refs []Ref `json:"refs,omitempty"`
Attrs json.RawMessage `json:"attrs,omitempty"`
}
// Relation is a typed edge that is not containment.
type Relation struct {
Entity
Type string `json:"type"`
From string `json:"from"`
To string `json:"to"`
Quality string `json:"quality"`
Via string `json:"via,omitempty"`
Evidence []Ref `json:"evidence,omitempty"`
}
// Unresolved is a reference that could not be resolved, carried as data.
//
// A resolved reference is superseded by a later revision whose State says so,
// rather than vanishing: absence never means resolved.
type Unresolved struct {
Entity
Kind string `json:"kind"`
RefID string `json:"ref,omitempty"`
Reason string `json:"reason,omitempty"`
State string `json:"state"` // open | resolved | terminal
}
// Unresolved states.
const (
UnresolvedOpen = "open"
// UnresolvedResolved supersedes an earlier open entry.
UnresolvedResolved = "resolved"
// UnresolvedTerminal means terminal evidence says it will never resolve -
// a pruned source, a record the runtime never wrote. It is never inferred
// from elapsed rounds.
UnresolvedTerminal = "terminal"
)
// Counts is the tally a commit frame publishes, so a reader can detect a
// truncated round without folding it.
type Counts struct {
Nodes int `json:"nodes"`
Relations int `json:"relations"`
Unresolved int `json:"unresolved"`
}
// Commit is the last line of a round.
//
// Digest covers every preceding line, so a round verifies itself: recompute
// over lines 1..n-1 and compare. It is also the value the NEXT round names as
// its Previous.
type Commit struct {
T FrameType `json:"t"`
Digest string `json:"digest"`
Counts Counts `json:"counts"`
}
// Validate reports a header that cannot be acted on.
func (h *Header) Validate() error {
switch {
case h.Schema != Schema:
return fmt.Errorf("sessionflow: unsupported schema %q, want %q", h.Schema, Schema)
case h.Conversation == "":
return fmt.Errorf("sessionflow: header missing conversation")
case h.Session == "":
return fmt.Errorf("sessionflow: header missing session")
case h.Round == 0:
return fmt.Errorf("sessionflow: round must count from 1")
case h.Round > 1 && h.Previous == "":
return fmt.Errorf("sessionflow: round %d has no previous digest; the chain would be unverifiable", h.Round)
case h.Round == 1 && h.Previous != "":
return fmt.Errorf("sessionflow: round 1 must not name a previous digest")
case h.Parser == "":
return fmt.Errorf("sessionflow: header missing parser version")
case h.Policy == "":
return fmt.Errorf("sessionflow: header missing policy version")
case h.InputDigest == "":
// Without it a round says nothing about the evidence it read, and the
// chain proves only that the rounds are the rounds.
return fmt.Errorf("sessionflow: header missing input digest")
case h.FromSeq == 0:
return fmt.Errorf("sessionflow: landed sequences count from 1, so from_seq must not be 0")
case h.ThroughSeq < h.FromSeq-1:
// through == from-1 is the empty range: a round that consumed no new
// evidence. Anything below that is not a range.
return fmt.Errorf("sessionflow: round %d consumes sequences %d..%d, which is not a range",
h.Round, h.FromSeq, h.ThroughSeq)
}
return nil
}