textmachine/platform/internal/ingest/manifest.go

66 lines
3.3 KiB
Go

package ingest
import (
"encoding/hex"
"encoding/json"
"fmt"
)
// Manifest is the ALLOWLISTED summary of the engine's chapter manifest (`tmctl manifest --json`,
// unified backlog row 100, form ratified with D39.122 as `tm-manifest-v2`).
//
// What is deliberately absent is most of the document: the per-chapter and per-unit arrays carry the
// engine's stable ids, its dense ordinals and its rendered headings, and none of them has a reader
// on this side yet — the platform's chapter tree is written by the materializer from the stream, not
// from here. Taking them now would store engine vocabulary nobody asks for and would have to be
// maintained against a document that re-cuts itself.
//
// What IS taken is what intake decides with: how many chapters the book has (the ceiling scale is
// built on it), and the two identifiers that say WHICH cut of WHICH bytes produced that number.
type Manifest struct {
// Version is `manifest_version`. Its VALUE is recorded and logged rather than gated — the fields
// below are counts and identities whose meaning is stable, and pinning the value here would make
// every engine release a platform release. Its PRESENCE is required (DecodeManifest), which is a
// different question: not "which manifest is this" but "is this a manifest at all".
Version string `json:"manifest_version"`
ChaptersTotal int `json:"chapters_total"`
UnitsTotal int `json:"units_total"`
// SourceSHA256 is the digest of the ingested source, hex. It answers "is the file on disk still
// the one that was cut" without the platform reading the file again.
SourceSHA256 string `json:"source_sha256"`
SourceBytes int64 `json:"source_bytes"`
// ChunkerVersion is one of the inputs of the manifest's validity key: a change re-numbers
// chapters, so a stored tree is only comparable within one of these (register row PD-166).
ChunkerVersion string `json:"chunker_version"`
}
// SourceSHA256Bytes is the digest as the column stores it. An unparsable value is stored as nothing
// rather than as garbage: the field is evidence, and evidence that cannot be decoded is absence.
func (m Manifest) SourceSHA256Bytes() []byte {
b, err := hex.DecodeString(m.SourceSHA256)
if err != nil {
return nil
}
return b
}
// DecodeManifest parses a manifest document, and refuses anything that does not identify itself as
// one.
//
// ⚠ The identity check is not decoration, and what it protects is the one path in this zone that
// DESTROYS a user's file. `{}`, `null` and any JSON object of fields this build has never heard of
// all decode into a Manifest of zeros, and intake reads a zero chapter count as "the engine read the
// source and there is no book in it" — the single verdict that removes the upload (books.Parse,
// books.reject). So a document that is not a manifest must not be able to arrive as an EMPTY one:
// the two are opposite facts, and only one of them is destructive. Raised by the seam lens of the
// dofix review.
func DecodeManifest(b []byte) (Manifest, error) {
var m Manifest
if err := json.Unmarshal(b, &m); err != nil {
return Manifest{}, fmt.Errorf("ingest: decode manifest: %w", err)
}
if m.Version == "" {
return Manifest{}, fmt.Errorf("ingest: decode manifest: the document carries no manifest_version, so it is not a manifest")
}
return m, nil
}