clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat: generalize replayable Atlas authoring

Dami committed 1b8abff
commit
1b8abff7b19393b01d0aa20cbd6e018f97f3083b
parent
3bfa9738ee9d1be90709172b83d5c3230ff643dd
author
Dami <dami.etoile@gmail.com>
21 files changed, +1994 -353
Mcmd/atlas/build.go8+-
Mdocs/authoring.md38+++-
Mexamples/sample-region.atlas-project95++++++----
Ainternal/authoring/adapter_test.go45+++++
Minternal/authoring/assemble.go83+++++---
Ainternal/authoring/assemble_contract_test.go66+++++++
Minternal/authoring/build.go71+++----
Minternal/authoring/build_test.go8+-
Minternal/authoring/capture.go216+++++++++++++++++++---
Minternal/authoring/capture_test.go258++++++++++++++++++++++++++
Ainternal/authoring/evidence.go139++++++++++++++
Ainternal/authoring/evidence_test.go106+++++++++++
Minternal/authoring/manifest.go295+++++++++++++++++++++---------
Minternal/authoring/manifest_test.go111++++++++++--
Minternal/authoring/observe.go94++++++---
Minternal/authoring/plan.go71++++++-
Ainternal/authoring/plan_test.go83++++++++
Minternal/workbench/assets/workbench.css153+++++++++++++++-
Minternal/workbench/operations_test.go94+++++++++-
Minternal/workbench/project.go191+++++++++++++++++++-
Minternal/workbench/templates/project.tmpl122++++++++++--
M cmd/atlas/build.go +6 -2
@@ -22,10 +22,11 @@ func buildCommand() command {
2222 }2323 2424 func runBuild(args []string) error {25- fs := flags("build", "[-cache DIR] [-bundles DIR] [-offline] [-plan-json] FILE.atlas-project")25+ fs := flags("build", "[-cache DIR] [-bundles DIR] [-offline | -replay RECEIPT_OR_ATLAS] [-plan-json] FILE.atlas-project")2626 cacheDir := fs.String("cache", "", "shared content-addressed source cache")2727 bundleDir := fs.String("bundles", "", "Atlas library to install the immutable build into")2828 offline := fs.Bool("offline", false, "use captured evidence only; perform no source requests")29+ replay := fs.String("replay", "", "rebuild from the exact evidence selected by a receipt JSON or Atlas file")2930 planJSON := fs.Bool("plan-json", false, "print the resolved plan without fetching or writing")3031 var logOptions logging.Options3132 logOptions.Bind(fs)
@@ -36,6 +37,9 @@ func runBuild(args []string) error {
3637 fs.Usage()3738 return errors.New("name exactly one .atlas-project manifest")3839 }40+ if *offline && *replay != "" {41+ return errors.New("-offline and -replay are mutually exclusive")42+ }3943 project := fs.Arg(0)4044 if *cacheDir == "" {4145 var err error
@@ -55,7 +59,7 @@ func runBuild(args []string) error {
5559 }5660 result, err := authoring.Build(context.Background(), authoring.BuildOptions{5761 ProjectPath: project, CacheDir: *cacheDir, LibraryDir: *bundleDir,58- Offline: *offline, PlanOnly: *planJSON,62+ Offline: *offline, ReplayPath: *replay, PlanOnly: *planJSON,5963 Event: func(event authoring.Event) {6064 attrs := []any{"stage", event.Stage}6165 if event.Current > 0 {
M docs/authoring.md +28 -10
@@ -30,6 +30,9 @@ go run ./cmd/atlas build examples/sample-region.atlas-project
3030 # Rebuild only from already captured evidence.3131 go run ./cmd/atlas build -offline examples/sample-region.atlas-project3232 33+# Rebuild from the exact evidence selected by an earlier Atlas or receipt.34+go run ./cmd/atlas build -replay previous-build.atlas examples/sample-region.atlas-project35+3336 # Edit, inspect, build, and hand the result to Atlas from the workbench.3437 go run ./cmd/atlas workbench examples/sample-region.atlas-project3538 ```
@@ -39,23 +42,34 @@ Their defaults are the application-owned cache and library.
3942 4043 ## Contract4144 42-- `atlas-project/v2` is an intentional hard break. Every feature source declares43- one homogeneous `point`, `path`, or `area` FeatureSet plus an executable44- source-to-world transform. Semantic types remain domain language rather than45- geometry labels.45+- `atlas-project/v2` is an intentional hard break. Top-level `feature-sets`46+ declare stable semantic membership, one homogeneous `point`, `path`, or47+ `area` geometry family, typed property contracts, and relationship contracts.48+ Semantic types remain domain language rather than geometry labels.49+- Feature adapters acquire evidence; source mappings only bind identity, title,50+ geometry transforms, property paths, and relationship paths to a declared51+ FeatureSet. An adapter cannot redefine a set's title, semantic type, geometry,52+ field types, or relationship meaning.4653 - The YAML decoder is strict. Unknown keys, duplicate identities, incomplete4754 mappings, broken presentation references, and invalid raster plans fail.4855 - Adapters describe acquisition; declarative mappings describe meaning.4956 Built-ins are `geojson`, `arcgis-feature-service`, `ogc-api-features`,50- `raster-file`, and explicit-window `xyz`.57+ `raster-file`, explicit-window `xyz`, and REST-template `wmts`.5158 - Planning performs no network or filesystem writes. Every request has a stable52- identity and reports cache presence, estimated bytes, and obligations.59+ binding identity plus a source-independent acquisition identity that includes60+ adapter version, portable locator, media type, and tile coordinates. The plan61+ reports validated cache presence, estimated bytes, and obligations; identical62+ acquisitions may be shared across projects without merging their semantics.5363 - Capture envelopes retain request identity, body hash, media type, timestamp,5464 licence, and attribution. Changed bodies append history instead of replacing55- evidence.56-- Observations retain source-native identity and evidence. Assembly merges only57- canonical identities declared by the manifest; earlier sources win conflicts58- and every contributor remains in provenance. Property contracts retain bool,65+ evidence. Cache reads verify the envelope, body length, and body SHA-256;66+ repeated or concurrent bytes retain one first-seen timestamp.67+- Observations retain source-native identity and evidence. Assembly begins from68+ the declared contracts, so empty sets and optional fields remain explicit in69+ the native result. It merges only canonical identities declared by the70+ manifest; earlier sources win conflicts and every contributor remains in71+ provenance. Required properties and relationships are checked after fusion,72+ allowing one source to complete another. Property contracts retain bool,5973 int64, float64, string, bytes, and stable-ID kinds; relationships name their6074 target FeatureSet and canonical feature identity.6175 - A local raster image is deterministically expanded into the declared native
@@ -67,6 +81,10 @@ Their defaults are the application-owned cache and library.
6781 - Publishing stages, syncs, reopens, validates, and atomically renames the6882 content-versioned `.atlas`. Rebuilding the same project from the same evidence6983 produces the same bytes and returns the existing file.84+- Every build embeds an executable v2 evidence receipt. `-replay` selects those85+ exact immutable capture hashes even when newer evidence exists. Mapping and86+ presentation changes may deliberately replay the same acquisitions, while a87+ changed locator, query, media type, or adapter version is refused as drift.7088 7189 ## Workbench7290
M examples/sample-region.atlas-project +57 -38
@@ -13,6 +13,51 @@ target:
1313 definition: local sample grid1414 extent: [0, 0, 100, 100]1515 16+feature-sets:17+ - id: places18+ title: Sample Places19+ semantic-type: place20+ geometry: point21+ properties:22+ - id: kind23+ name: Kind24+ type: string25+ - id: featured26+ name: Featured27+ type: bool28+ - id: rank29+ name: Rank30+ type: int6431+ - id: score32+ name: Score33+ type: float6434+ - id: badge35+ name: Badge36+ type: bytes37+ - id: category38+ name: Category39+ type: id40+ relationships:41+ - id: area42+ predicate: within43+ feature-set: areas44+ - id: routes45+ title: Sample Routes46+ semantic-type: route47+ geometry: path48+ properties:49+ - id: kind50+ name: Kind51+ type: string52+ - id: areas53+ title: Sample Areas54+ semantic-type: region55+ geometry: area56+ properties:57+ - id: kind58+ name: Kind59+ type: string60+1661 sources:1762 - id: sample-places1863 adapter: geojson
@@ -23,44 +68,28 @@ sources:
2368 estimate-bytes: 20482469 mapping:2570 feature-set: places26- title: Sample Places27- semantic-type: place2871 identity: id2972 feature-title: properties.name3073 geometry:31- family: point3274 source-space: sample-source-grid3375 transform:3476 kind: affine3577 matrix: [10, 0, 0, 0, 10, 0]36- fields:37- - id: kind38- name: Kind78+ properties:79+ - field: kind3980 source: properties.kind40- type: string41- - id: featured42- name: Featured81+ - field: featured4382 source: properties.featured44- type: bool45- - id: rank46- name: Rank83+ - field: rank4784 source: properties.rank48- type: int6449- - id: score50- name: Score85+ - field: score5186 source: properties.score52- type: float6453- - id: badge54- name: Badge87+ - field: badge5588 source: properties.badge56- type: bytes57- - id: category58- name: Category89+ - field: category5990 source: properties.category60- type: id6191 relationships:62- - predicate: within63- feature-set: areas92+ - relationship: area6493 target: properties.area6594 6695 - id: sample-routes
@@ -72,20 +101,15 @@ sources:
72101 estimate-bytes: 102473102 mapping:74103 feature-set: routes75- title: Sample Routes76- semantic-type: route77104 identity: id78105 feature-title: properties.name79106 geometry:80- family: path81107 source-space: sample-grid82108 transform:83109 kind: identity84- fields:85- - id: kind86- name: Kind110+ properties:111+ - field: kind87112 source: properties.kind88- type: string89113 90114 - id: sample-areas91115 adapter: geojson
@@ -96,20 +120,15 @@ sources:
96120 estimate-bytes: 102497121 mapping:98122 feature-set: areas99- title: Sample Areas100- semantic-type: region101123 identity: id102124 feature-title: properties.name103125 geometry:104- family: area105126 source-space: sample-grid106127 transform:107128 kind: identity108- fields:109- - id: kind110- name: Kind129+ properties:130+ - field: kind111131 source: properties.kind112- type: string113132 114133 rasters:115134 - id: background
A internal/authoring/adapter_test.go +45 -0
M internal/authoring/assemble.go +50 -33
@@ -11,8 +11,9 @@ import (
1111 )1212 1313 type assembledFeature struct {14- feature vnext.Feature15- values map[string]vnext.Property14+ feature vnext.Feature15+ values map[string]vnext.Property16+ relationships map[string]bool1617 }1718 1819 func assemble(project Project, observations []observation) (vnext.Volume, error) {
@@ -36,31 +37,42 @@ func assemble(project Project, observations []observation) (vnext.Volume, error)
3637 return observations[i].SourceOrder < observations[j].SourceOrder3738 })3839 sets := make(map[string]int)39- families := make(map[string]string)40+ contracts := make(map[string]FeatureSetContract)41+ fields := make(map[string]map[string]vnext.Field)4042 features := make(map[string]map[string]*assembledFeature)43+ for _, contract := range project.FeatureSets {44+ set := vnext.FeatureSet{45+ ID: setID(world.ID, contract.ID), Title: contract.Title, SemanticType: contract.SemanticType,46+ Claims: []vnext.Property{geometryClaim(contract.Geometry)},47+ }48+ fields[contract.ID] = make(map[string]vnext.Field, len(contract.Properties))49+ for _, property := range contract.Properties {50+ kind, err := kindOf(property.Type)51+ if err != nil {52+ return vnext.Volume{}, fmt.Errorf("feature set %s property %s: %w", contract.ID, property.ID, err)53+ }54+ field := vnext.Field{55+ ID: vnext.IDFromName(project.SchemaNamespace, "feature."+contract.ID+"."+property.ID),56+ Name: property.Name, Kind: kind, Optional: property.Optional,57+ }58+ set.Properties = append(set.Properties, field)59+ fields[contract.ID][property.ID] = field60+ }61+ sort.Slice(set.Properties, func(i, j int) bool { return set.Properties[i].ID.String() < set.Properties[j].ID.String() })62+ world.FeatureSets = append(world.FeatureSets, set)63+ sets[contract.ID] = len(world.FeatureSets) - 164+ contracts[contract.ID] = contract65+ features[contract.ID] = make(map[string]*assembledFeature)66+ }4167 for _, item := range observations {42- setIndex, exists := sets[item.FeatureSet]68+ _, exists := sets[item.FeatureSet]4369 if !exists {44- world.FeatureSets = append(world.FeatureSets, vnext.FeatureSet{45- ID: setID(world.ID, item.FeatureSet), Title: item.SetTitle, SemanticType: item.Semantic,46- Claims: []vnext.Property{geometryClaim(item.Family)},47- })48- setIndex = len(world.FeatureSets) - 149- sets[item.FeatureSet] = setIndex50- families[item.FeatureSet] = item.Family51- features[item.FeatureSet] = make(map[string]*assembledFeature)70+ return vnext.Volume{}, fmt.Errorf("observation targets undeclared feature set %s", item.FeatureSet)5271 }53- set := &world.FeatureSets[setIndex]54- if set.Title != item.SetTitle || set.SemanticType != item.Semantic || families[item.FeatureSet] != item.Family || familyOf(item.Geometry.Kind) != item.Family {72+ contract := contracts[item.FeatureSet]73+ if familyOf(item.Geometry.Kind) != contract.Geometry {5574 return vnext.Volume{}, fmt.Errorf("feature set %s has incompatible observations", item.FeatureSet)5675 }57- for key, field := range item.Fields {58- if current, held := fieldByID(set.Properties, field.ID); held && (current.Kind != field.Kind || current.Name != field.Name || current.Optional != field.Optional) {59- return vnext.Volume{}, fmt.Errorf("feature set %s field %s changes type", item.FeatureSet, key)60- } else if !held {61- set.Properties = append(set.Properties, field)62- }63- }6476 held := features[item.FeatureSet][item.NativeID]6577 if held == nil {6678 held = &assembledFeature{
@@ -68,7 +80,7 @@ func assemble(project Project, observations []observation) (vnext.Volume, error)
6880 ID: featureID(world.ID, item.FeatureSet, item.NativeID), Title: item.Title,6981 Geometry: item.Geometry,7082 },71- values: make(map[string]vnext.Property),83+ values: make(map[string]vnext.Property), relationships: make(map[string]bool),7284 }7385 features[item.FeatureSet][item.NativeID] = held7486 }
@@ -77,7 +89,10 @@ func assemble(project Project, observations []observation) (vnext.Volume, error)
7789 })7890 for key, value := range item.Values {7991 if _, exists := held.values[key]; !exists {80- field := item.Fields[key]92+ field, exists := fields[item.FeatureSet][key]93+ if !exists {94+ return vnext.Volume{}, fmt.Errorf("feature set %s has no property %s", item.FeatureSet, key)95+ }8196 held.values[key] = vnext.Property{FieldID: field.ID, Field: field, Value: value}8297 }8398 }
@@ -86,13 +101,24 @@ func assemble(project Project, observations []observation) (vnext.Volume, error)
86101 Predicate: relation.Predicate,87102 Target: featureID(world.ID, relation.FeatureSet, relation.NativeID),88103 })104+ held.relationships[relation.Contract] = true89105 }90106 }91107 for _, setName := range sortedSetNames(sets) {92108 set := &world.FeatureSets[sets[setName]]93- sort.Slice(set.Properties, func(i, j int) bool { return set.Properties[i].ID.String() < set.Properties[j].ID.String() })109+ contract := contracts[setName]94110 for _, nativeID := range sortedFeatureNames(features[setName]) {95111 held := features[setName][nativeID]112+ for _, property := range contract.Properties {113+ if _, exists := held.values[property.ID]; !property.Optional && !exists {114+ return vnext.Volume{}, fmt.Errorf("feature %s omits required property %s", held.feature.ID, property.ID)115+ }116+ }117+ for _, relation := range contract.Relationships {118+ if !relation.Optional && !held.relationships[relation.ID] {119+ return vnext.Volume{}, fmt.Errorf("feature %s omits required relationship %s", held.feature.ID, relation.ID)120+ }121+ }96122 for _, key := range sortedPropertyNames(held.values) {97123 held.feature.Properties = append(held.feature.Properties, held.values[key])98124 }
@@ -175,15 +201,6 @@ func appendRelationship(values []vnext.Relationship, item vnext.Relationship) []
175201 return append(values, item)176202 }177203 178-func fieldByID(fields []vnext.Field, id vnext.ID) (vnext.Field, bool) {179- for _, field := range fields {180- if field.ID == id {181- return field, true182- }183- }184- return vnext.Field{}, false185-}186-187204 func relationshipClosure(world vnext.World) error {188205 ids := make(map[string]bool)189206 for _, set := range world.FeatureSets {
A internal/authoring/assemble_contract_test.go +66 -0
@@ -0,0 +1,66 @@
1+package authoring2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/atlas/format/vnext"8+)9+10+func TestAssembleSeedsDeclaredFeatureSetContracts(t *testing.T) {11+ project := contractTestProject()12+ project.FeatureSets = append(project.FeatureSets, FeatureSetContract{13+ ID: "areas", Title: "Sample Areas", SemanticType: "region", Geometry: "area",14+ })15+ volume, err := assemble(project, nil)16+ if err != nil {17+ t.Fatal(err)18+ }19+ world := volume.Worlds[0]20+ if len(world.FeatureSets) != 2 {21+ t.Fatalf("feature sets = %d, want two declared empty sets", len(world.FeatureSets))22+ }23+ places := world.FeatureSets[1]24+ if places.ID != "sample-region/set/places" || len(places.Properties) != 1 || len(places.Features) != 0 {25+ t.Fatalf("places contract = %+v", places)26+ }27+ if !hasGeometryClaim(places.Claims, "point") {28+ t.Fatalf("places claims = %+v, want point contract", places.Claims)29+ }30+ wantFieldID := vnext.IDFromName(project.SchemaNamespace, "feature.places.kind")31+ if places.Properties[0].ID != wantFieldID {32+ t.Fatalf("field ID = %s, want source-independent %s", places.Properties[0].ID, wantFieldID)33+ }34+}35+36+func TestAssembleChecksRequiredPropertiesAfterFusion(t *testing.T) {37+ project := contractTestProject()38+ geometry := vnext.Geometry{Kind: vnext.GeometryPoint, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{{1, 1}}}}}}39+ first := observation{SourceOrder: 0, Source: "primary", NativeID: "one", FeatureSet: "places", Title: "Sample Place", Geometry: geometry, Values: map[string]vnext.Value{}}40+ second := observation{SourceOrder: 1, Source: "secondary", NativeID: "one", FeatureSet: "places", Title: "Sample Place", Geometry: geometry, Values: map[string]vnext.Value{"kind": vnext.StringValue("sample")}}41+ if _, err := assemble(project, []observation{first}); err == nil || !strings.Contains(err.Error(), "omits required property kind") {42+ t.Fatalf("assemble = %v, want required-property refusal", err)43+ }44+ volume, err := assemble(project, []observation{first, second})45+ if err != nil {46+ t.Fatal(err)47+ }48+ feature := volume.Worlds[0].FeatureSets[0].Features[0]49+ if len(feature.Properties) != 1 || feature.Properties[0].Value.String != "sample" || len(feature.Provenance) != 2 {50+ t.Fatalf("fused feature = %+v", feature)51+ }52+}53+54+func contractTestProject() Project {55+ return Project{56+ Schema: ProjectSchema, SchemaNamespace: "example.invalid/atlas/sample-region", ID: "sample-region", Title: "Sample Region",57+ Target: Target{World: "sample-region", Title: "Sample Region", CoordinateSpace: CoordinateSpace{58+ ID: "sample-space", Kind: "projected", Unit: "metre", Definition: "sample grid", Extent: [4]float64{0, 0, 10, 10},59+ }},60+ FeatureSets: []FeatureSetContract{{61+ ID: "places", Title: "Sample Places", SemanticType: "place", Geometry: "point",62+ Properties: []PropertyContract{{ID: "kind", Name: "Kind", Type: "string"}},63+ }},64+ Presentation: Presentation{ID: "default", Title: "Default"},65+ }66+}
M internal/authoring/build.go +32 -39
@@ -2,7 +2,6 @@ package authoring
22 33 import (44 "context"5- "encoding/json"65 "fmt"76 "mime"87 "os"
@@ -28,6 +27,7 @@ type BuildOptions struct {
2827 CacheDir string2928 LibraryDir string3029 Offline bool30+ ReplayPath string3131 PlanOnly bool3232 Event func(Event)3333 }
@@ -54,17 +54,40 @@ func Build(ctx context.Context, options BuildOptions) (BuildResult, error) {
5454 if options.PlanOnly {5555 return result, nil5656 }57- captures := make(map[string]Capture, len(plan.Requests))58- for index, planned := range plan.Requests {59- capture, cached, err := cache.Acquire(ctx, planned.Request, options.Offline)57+ var captures map[string]Capture58+ if options.ReplayPath != "" {59+ selection, err := loadEvidenceSelection(options.ReplayPath)6060 if err != nil {6161 return result, err6262 }63- captures[planned.ID] = capture64- emit(options, Event{65- Stage: "capture", Message: planned.Source, Current: index + 1,66- Total: len(plan.Requests), Bytes: capture.Body.Length, Cached: cached,67- })63+ captures, err = replayCaptures(plan, selection, cache)64+ if err != nil {65+ return result, err66+ }67+ for index, planned := range plan.Requests {68+ capture := captures[planned.ID]69+ emit(options, Event{Stage: "capture", Message: planned.Source, Current: index + 1, Total: len(plan.Requests), Bytes: capture.Body.Length, Cached: true})70+ }71+ } else {72+ captures = make(map[string]Capture, len(plan.Requests))73+ acquisitions := make(map[string]Capture, len(plan.Requests))74+ for index, planned := range plan.Requests {75+ key := requestCacheKey(planned.Request)76+ capture, cached := acquisitions[key], true77+ if capture.RequestHash == "" {78+ var err error79+ capture, cached, err = cache.Acquire(ctx, planned.Request, options.Offline)80+ if err != nil {81+ return result, err82+ }83+ acquisitions[key] = capture84+ }85+ captures[planned.ID] = capture86+ emit(options, Event{87+ Stage: "capture", Message: planned.Source, Current: index + 1,88+ Total: len(plan.Requests), Bytes: capture.Body.Length, Cached: cached,89+ })90+ }6891 }6992 var observations []observation7093 for sourceIndex, source := range project.Sources {
@@ -225,36 +248,6 @@ func formatOf(mediaType, locator string) string {
225248 return strings.ToLower(extension)226249 }227250 228-func buildReceipt(plan Plan, captures map[string]Capture) ([]byte, string, error) {229- type selected struct {230- Request string `json:"request"`231- Source string `json:"source"`232- SHA256 string `json:"sha256"`233- }234- receipt := struct {235- Format string `json:"format"`236- Project string `json:"project"`237- ProjectDigest string `json:"projectDigest"`238- Captures []selected `json:"captures"`239- }{Format: "atlas-build-receipt/v1", Project: plan.Project, ProjectDigest: plan.ProjectDigest}240- createdAt := ""241- for _, request := range plan.Requests {242- capture := captures[request.ID]243- receipt.Captures = append(receipt.Captures, selected{Request: request.ID, Source: request.Source, SHA256: capture.Body.SHA256})244- if capture.CapturedAt > createdAt {245- createdAt = capture.CapturedAt246- }247- }248- if createdAt == "" {249- return nil, "", fmt.Errorf("build selects no captured evidence")250- }251- data, err := json.Marshal(receipt)252- if err != nil {253- return nil, "", err254- }255- return append(data, '\n'), createdAt, nil256-}257-258251 func writeBuild(library string, bundle vnext.Bundle) (vnext.Descriptor, string, bool, error) {259252 if library == "" {260253 return vnext.Descriptor{}, "", false, fmt.Errorf("build has no Atlas library")
M internal/authoring/build_test.go +2 -6
M internal/authoring/capture.go +182 -34
@@ -1,6 +1,7 @@
11 package authoring22 33 import (4+ "bytes"45 "context"56 "crypto/sha256"67 "encoding/hex"
@@ -18,6 +19,10 @@ import (
1819 1920 const CaptureFormat = "atlas-capture/v1"2021 22+const sha256HexLength = sha256.Size * 223+24+var errExistingContent = errors.New("path already holds different content")25+2126 type BlobRef struct {2227 SHA256 string `json:"sha256"`2328 Length int64 `json:"length"`
@@ -61,7 +66,10 @@ func (cache *Cache) Has(requestID string) bool {
6166 }6267 6368 func (cache *Cache) Latest(requestID string) (Capture, error) {64- dir := filepath.Join(cache.root, "captures", requestID[:2], requestID)69+ dir, err := cache.captureDir(requestID)70+ if err != nil {71+ return Capture{}, err72+ }6573 entries, err := os.ReadDir(dir)6674 if err != nil {6775 return Capture{}, err
@@ -72,13 +80,10 @@ func (cache *Cache) Latest(requestID string) (Capture, error) {
7280 if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {7381 continue7482 }75- data, err := os.ReadFile(filepath.Join(dir, entry.Name()))83+ bodySHA := strings.TrimSuffix(entry.Name(), ".json")84+ capture, err := cache.Select(requestID, bodySHA)7685 if err != nil {77- return Capture{}, err78- }79- var capture Capture80- if err := json.Unmarshal(data, &capture); err != nil {81- return Capture{}, fmt.Errorf("decode capture %s: %w", entry.Name(), err)86+ return Capture{}, fmt.Errorf("validate capture %s: %w", entry.Name(), err)8287 }8388 captures = append(captures, capture)8489 }
@@ -94,15 +99,47 @@ func (cache *Cache) Latest(requestID string) (Capture, error) {
9499 return captures[len(captures)-1], nil95100 }96101 102+// Select returns one exact, validated capture for a request and body hash.103+func (cache *Cache) Select(requestID, bodySHA string) (Capture, error) {104+ dir, err := cache.captureDir(requestID)105+ if err != nil {106+ return Capture{}, err107+ }108+ if err := validateSHA256("body", bodySHA); err != nil {109+ return Capture{}, err110+ }111+ path := filepath.Join(dir, bodySHA+".json")112+ data, err := os.ReadFile(path)113+ if err != nil {114+ return Capture{}, fmt.Errorf("read capture %s: %w", bodySHA, err)115+ }116+ capture, err := decodeCapture(data)117+ if err != nil {118+ return Capture{}, fmt.Errorf("decode capture %s: %w", bodySHA, err)119+ }120+ if err := cache.validateCapture(capture, requestID, bodySHA); err != nil {121+ return Capture{}, err122+ }123+ return capture, nil124+}125+97126 func (cache *Cache) BlobPath(ref BlobRef) string {98- return filepath.Join(cache.root, "blobs", "sha256", ref.SHA256[:2], ref.SHA256)127+ path, err := cache.blobPath(ref.SHA256)128+ if err != nil {129+ return filepath.Join(cache.root, "blobs", "sha256", "invalid")130+ }131+ return path99132 }100133 101134 // Acquire fetches or reads one planned request, records only changed bytes and102135 // returns the immutable capture selected for this build.103136 func (cache *Cache) Acquire(ctx context.Context, request Request, offline bool) (Capture, bool, error) {137+ requestKey := requestCacheKey(request)138+ if err := validateSHA256("request", requestKey); err != nil {139+ return Capture{}, false, err140+ }104141 if offline {105- capture, err := cache.Latest(request.ID)142+ capture, err := cache.Latest(requestKey)106143 if err != nil {107144 return Capture{}, false, fmt.Errorf("request %s is not cached: %w", request.Source, err)108145 }
@@ -114,8 +151,10 @@ func (cache *Cache) Acquire(ctx context.Context, request Request, offline bool)
114151 }115152 digest := sha256.Sum256(body)116153 hash := hex.EncodeToString(digest[:])117- if current, err := cache.Latest(request.ID); err == nil && current.Body.SHA256 == hash {118- return current, true, nil154+ if held, err := cache.Select(requestKey, hash); err == nil {155+ return held, true, nil156+ } else if !errors.Is(err, os.ErrNotExist) {157+ return Capture{}, false, fmt.Errorf("select captured source %s: %w", request.Source, err)119158 }120159 if mediaType == "" {121160 mediaType = request.MediaType
@@ -124,11 +163,15 @@ func (cache *Cache) Acquire(ctx context.Context, request Request, offline bool)
124163 mediaType = "application/octet-stream"125164 }126165 ref := BlobRef{SHA256: hash, Length: int64(len(body)), MediaType: mediaType}127- if err := writeOnce(cache.BlobPath(ref), body); err != nil {166+ blobPath, err := cache.blobPath(hash)167+ if err != nil {168+ return Capture{}, false, err169+ }170+ if _, err := writeOnce(blobPath, body); err != nil {128171 return Capture{}, false, fmt.Errorf("cache evidence: %w", err)129172 }130173 capture := Capture{131- Format: CaptureFormat, RequestHash: request.ID, Source: request.Source,174+ Format: CaptureFormat, RequestHash: requestKey, Source: request.Source,132175 Adapter: request.Adapter, Kind: string(request.Kind), Locator: request.IdentityLocator,133176 CapturedAt: cache.now().UTC().Format(time.RFC3339Nano),134177 License: request.License, Attribution: request.Attribution, Body: ref,
@@ -138,11 +181,106 @@ func (cache *Cache) Acquire(ctx context.Context, request Request, offline bool)
138181 return Capture{}, false, err139182 }140183 data = append(data, '\n')141- path := filepath.Join(cache.root, "captures", request.ID[:2], request.ID, hash+".json")142- if err := writeOnce(path, data); err != nil {143- return Capture{}, false, fmt.Errorf("cache capture: %w", err)184+ dir, err := cache.captureDir(requestKey)185+ if err != nil {186+ return Capture{}, false, err187+ }188+ path := filepath.Join(dir, hash+".json")189+ written, writeErr := writeOnce(path, data)190+ if writeErr != nil && !errors.Is(writeErr, errExistingContent) {191+ return Capture{}, false, fmt.Errorf("cache capture: %w", writeErr)192+ }193+ selected, err := cache.Select(requestKey, hash)194+ if err != nil {195+ return Capture{}, false, fmt.Errorf("validate cached capture: %w", err)196+ }197+ return selected, !written, nil198+}199+200+func (cache *Cache) captureDir(requestID string) (string, error) {201+ if err := validateSHA256("request", requestID); err != nil {202+ return "", err203+ }204+ return filepath.Join(cache.root, "captures", requestID[:2], requestID), nil205+}206+207+func (cache *Cache) blobPath(bodySHA string) (string, error) {208+ if err := validateSHA256("body", bodySHA); err != nil {209+ return "", err210+ }211+ return filepath.Join(cache.root, "blobs", "sha256", bodySHA[:2], bodySHA), nil212+}213+214+func (cache *Cache) validateCapture(capture Capture, requestID, bodySHA string) error {215+ if capture.Format != CaptureFormat {216+ return fmt.Errorf("capture format %q, want %q", capture.Format, CaptureFormat)217+ }218+ if capture.RequestHash != requestID {219+ return fmt.Errorf("capture request hash %q does not match %q", capture.RequestHash, requestID)220+ }221+ if capture.Source == "" || capture.Adapter == "" || capture.Locator == "" {222+ return fmt.Errorf("capture envelope is incomplete")223+ }224+ switch RequestKind(capture.Kind) {225+ case RequestFeatures, RequestRaster, RequestAsset:226+ default:227+ return fmt.Errorf("capture kind %q is invalid", capture.Kind)228+ }229+ if _, err := time.Parse(time.RFC3339Nano, capture.CapturedAt); err != nil {230+ return fmt.Errorf("capture time %q: %w", capture.CapturedAt, err)231+ }232+ if capture.Body.SHA256 != bodySHA {233+ return fmt.Errorf("capture body %q does not match path %q", capture.Body.SHA256, bodySHA)234+ }235+ if capture.Body.Length < 0 || strings.TrimSpace(capture.Body.MediaType) == "" {236+ return fmt.Errorf("capture body metadata is incomplete")237+ }238+ return cache.validateBlob(capture.Body)239+}240+241+func (cache *Cache) validateBlob(ref BlobRef) error {242+ path, err := cache.blobPath(ref.SHA256)243+ if err != nil {244+ return err245+ }246+ data, err := os.ReadFile(path)247+ if err != nil {248+ return fmt.Errorf("read captured body %s: %w", ref.SHA256, err)249+ }250+ if int64(len(data)) != ref.Length {251+ return fmt.Errorf("captured body %s length is %d, want %d", ref.SHA256, len(data), ref.Length)252+ }253+ digest := sha256.Sum256(data)254+ if actual := hex.EncodeToString(digest[:]); actual != ref.SHA256 {255+ return fmt.Errorf("captured body hash is %s, want %s", actual, ref.SHA256)256+ }257+ return nil258+}259+260+func decodeCapture(data []byte) (Capture, error) {261+ decoder := json.NewDecoder(bytes.NewReader(data))262+ decoder.DisallowUnknownFields()263+ var capture Capture264+ if err := decoder.Decode(&capture); err != nil {265+ return Capture{}, err266+ }267+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {268+ if err == nil {269+ return Capture{}, fmt.Errorf("capture carries more than one JSON value")270+ }271+ return Capture{}, fmt.Errorf("decode capture trailer: %w", err)272+ }273+ return capture, nil274+}275+276+func validateSHA256(name, value string) error {277+ if len(value) != sha256HexLength || strings.ToLower(value) != value {278+ return fmt.Errorf("%s SHA-256 %q is not lowercase hexadecimal", name, value)279+ }280+ if _, err := hex.DecodeString(value); err != nil {281+ return fmt.Errorf("%s SHA-256 %q: %w", name, value, err)144282 }145- return capture, false, nil283+ return nil146284 }147285 148286 func (cache *Cache) read(ctx context.Context, request Request) ([]byte, string, error) {
@@ -176,40 +314,50 @@ func (cache *Cache) read(ctx context.Context, request Request) ([]byte, string,
176314 return body, mediaType, nil177315 }178316 179-func writeOnce(path string, data []byte) error {180- if _, err := os.Stat(path); err == nil {181- return nil317+func writeOnce(path string, data []byte) (bool, error) {318+ if held, err := os.ReadFile(path); err == nil {319+ if !bytes.Equal(held, data) {320+ return false, fmt.Errorf("%w at %s", errExistingContent, path)321+ }322+ return false, nil182323 } else if !errors.Is(err, os.ErrNotExist) {183- return err324+ return false, err184325 }185326 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {186- return err327+ return false, err187328 }188329 stage, err := os.CreateTemp(filepath.Dir(path), ".writing-*")189330 if err != nil {190- return err331+ return false, err191332 }192333 name := stage.Name()193334 defer os.Remove(name)194335 if _, err := stage.Write(data); err != nil {195- stage.Close()196- return err336+ _ = stage.Close()337+ return false, err197338 }198339 if err := stage.Sync(); err != nil {199- stage.Close()200- return err340+ _ = stage.Close()341+ return false, err201342 }202343 if err := stage.Close(); err != nil {203- return err344+ return false, err204345 }205346 if err := os.Chmod(name, 0o644); err != nil {206- return err347+ return false, err207348 }208- if err := os.Rename(name, path); err != nil {209- if _, statErr := os.Stat(path); statErr == nil {210- return nil349+ if err := os.Link(name, path); err != nil {350+ if !errors.Is(err, os.ErrExist) {351+ return false, err211352 }212- return err353+ held, readErr := os.ReadFile(path)354+ if readErr != nil {355+ return false, readErr356+ }357+ if !bytes.Equal(held, data) {358+ return false, fmt.Errorf("%w at %s", errExistingContent, path)359+ }360+ return false, nil213361 }214- return nil362+ return true, nil215363 }
M internal/authoring/capture_test.go +258 -0
@@ -2,8 +2,12 @@ package authoring
22 33 import (44 "context"5+ "encoding/json"56 "os"67 "path/filepath"8+ "strings"9+ "sync"10+ "sync/atomic"711 "testing"812 "time"913 )
@@ -59,3 +63,257 @@ func TestCaptureRecordsChangedEvidence(t *testing.T) {
5963 t.Fatalf("change was not recorded: first %+v second %+v", first, second)6064 }6165 }66+67+func TestCaptureReusesFirstSeenEvidenceWhenBytesReappear(t *testing.T) {68+ root := t.TempDir()69+ source := filepath.Join(root, "sample-region.geojson")70+ cache := OpenCache(filepath.Join(root, "cache"))71+ clock := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)72+ cache.now = func() time.Time { return clock }73+ request := Request{74+ Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson",75+ Locator: source, IdentityLocator: "sample-region.geojson",76+ }77+ request.ID = requestID(request)78+79+ writeSource := func(body string) {80+ t.Helper()81+ if err := os.WriteFile(source, []byte(body), 0o644); err != nil {82+ t.Fatal(err)83+ }84+ }85+ writeSource(`{"v":1}`)86+ first, _, err := cache.Acquire(context.Background(), request, false)87+ if err != nil {88+ t.Fatal(err)89+ }90+ clock = clock.Add(time.Hour)91+ writeSource(`{"v":2}`)92+ if _, _, err := cache.Acquire(context.Background(), request, false); err != nil {93+ t.Fatal(err)94+ }95+ clock = clock.Add(time.Hour)96+ writeSource(`{"v":1}`)97+ reappeared, cached, err := cache.Acquire(context.Background(), request, false)98+ if err != nil || !cached {99+ t.Fatalf("reappeared acquire = cached %v, err %v", cached, err)100+ }101+ if reappeared != first {102+ t.Fatalf("reappeared capture moved: first %+v reappeared %+v", first, reappeared)103+ }104+ selected, err := cache.Select(request.ID, first.Body.SHA256)105+ if err != nil || selected != first {106+ t.Fatalf("select first capture = %+v, %v", selected, err)107+ }108+}109+110+func TestConcurrentCaptureReturnsOnePersistedFirstSeenEnvelope(t *testing.T) {111+ root := t.TempDir()112+ source := filepath.Join(root, "sample-region.geojson")113+ if err := os.WriteFile(source, []byte(`{"v":1}`), 0o644); err != nil {114+ t.Fatal(err)115+ }116+ cache := OpenCache(filepath.Join(root, "cache"))117+ var ticks atomic.Int64118+ cache.now = func() time.Time {119+ return time.Date(2026, 8, 8, 12, 0, 0, int(ticks.Add(1)), time.UTC)120+ }121+ request := Request{122+ Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson",123+ Locator: source, IdentityLocator: "sample-region.geojson",124+ }125+ request.ID = requestID(request)126+127+ type result struct {128+ capture Capture129+ cached bool130+ err error131+ }132+ results := make(chan result, 2)133+ start := make(chan struct{})134+ var workers sync.WaitGroup135+ for range 2 {136+ workers.Add(1)137+ go func() {138+ defer workers.Done()139+ <-start140+ capture, cached, err := cache.Acquire(context.Background(), request, false)141+ results <- result{capture: capture, cached: cached, err: err}142+ }()143+ }144+ close(start)145+ workers.Wait()146+ close(results)147+148+ var captures []Capture149+ cachedCount := 0150+ for result := range results {151+ if result.err != nil {152+ t.Fatal(result.err)153+ }154+ captures = append(captures, result.capture)155+ if result.cached {156+ cachedCount++157+ }158+ }159+ if captures[0] != captures[1] || cachedCount != 1 {160+ t.Fatalf("concurrent captures = %+v, cached count %d", captures, cachedCount)161+ }162+ selected, err := cache.Select(request.ID, captures[0].Body.SHA256)163+ if err != nil || selected != captures[0] {164+ t.Fatalf("persisted capture = %+v, %v", selected, err)165+ }166+}167+168+func TestCacheRejectsInvalidHashesWithoutPanicking(t *testing.T) {169+ cache := OpenCache(t.TempDir())170+ if cache.Has("short") {171+ t.Fatal("short request hash reported cached")172+ }173+ if _, err := cache.Latest("short"); err == nil {174+ t.Fatal("Latest accepted a short request hash")175+ }176+ if _, err := cache.Select(strings.Repeat("a", 64), "../escape"); err == nil {177+ t.Fatal("Select accepted a path-like body hash")178+ }179+ _ = cache.BlobPath(BlobRef{SHA256: "short"})180+}181+182+func TestCacheRejectsCorruptCaptureEvidence(t *testing.T) {183+ tests := []struct {184+ name string185+ mutate func(t *testing.T, cache *Cache, request Request, capture Capture)186+ }{187+ {188+ name: "malformed envelope",189+ mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {190+ t.Helper()191+ writeCaptureFile(t, cache, request.ID, capture.Body.SHA256, []byte("{"))192+ },193+ },194+ {195+ name: "wrong format",196+ mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {197+ capture.Format = "atlas-capture/unknown"198+ writeCapture(t, cache, request.ID, capture.Body.SHA256, capture)199+ },200+ },201+ {202+ name: "wrong request hash",203+ mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {204+ capture.RequestHash = strings.Repeat("b", 64)205+ writeCapture(t, cache, request.ID, capture.Body.SHA256, capture)206+ },207+ },208+ {209+ name: "wrong capture path",210+ mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {211+ wrong := strings.Repeat("b", 64)212+ writeCapture(t, cache, request.ID, wrong, capture)213+ if err := os.Remove(captureFilePath(cache, request.ID, capture.Body.SHA256)); err != nil {214+ t.Fatal(err)215+ }216+ },217+ },218+ {219+ name: "invalid captured time",220+ mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {221+ capture.CapturedAt = "eventually"222+ writeCapture(t, cache, request.ID, capture.Body.SHA256, capture)223+ },224+ },225+ {226+ name: "missing media type",227+ mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {228+ capture.Body.MediaType = ""229+ writeCapture(t, cache, request.ID, capture.Body.SHA256, capture)230+ },231+ },232+ {233+ name: "wrong declared length",234+ mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {235+ capture.Body.Length++236+ writeCapture(t, cache, request.ID, capture.Body.SHA256, capture)237+ },238+ },239+ {240+ name: "missing body",241+ mutate: func(t *testing.T, cache *Cache, _ Request, capture Capture) {242+ if err := os.Remove(cache.BlobPath(capture.Body)); err != nil {243+ t.Fatal(err)244+ }245+ },246+ },247+ {248+ name: "body hash mismatch",249+ mutate: func(t *testing.T, cache *Cache, _ Request, capture Capture) {250+ path := cache.BlobPath(capture.Body)251+ data, err := os.ReadFile(path)252+ if err != nil {253+ t.Fatal(err)254+ }255+ data[0] ^= 1256+ if err := os.WriteFile(path, data, 0o644); err != nil {257+ t.Fatal(err)258+ }259+ },260+ },261+ }262+263+ for _, tt := range tests {264+ t.Run(tt.name, func(t *testing.T) {265+ cache, request, capture := capturedFixture(t)266+ tt.mutate(t, cache, request, capture)267+ if cache.Has(request.ID) {268+ t.Fatal("corrupt evidence reported cached")269+ }270+ if _, err := cache.Latest(request.ID); err == nil {271+ t.Fatal("Latest accepted corrupt evidence")272+ }273+ if _, err := cache.Select(request.ID, capture.Body.SHA256); err == nil && tt.name != "wrong capture path" {274+ t.Fatal("Select accepted corrupt evidence")275+ }276+ })277+ }278+}279+280+func capturedFixture(t *testing.T) (*Cache, Request, Capture) {281+ t.Helper()282+ root := t.TempDir()283+ source := filepath.Join(root, "sample-region.geojson")284+ if err := os.WriteFile(source, []byte(`{"type":"FeatureCollection","features":[]}`), 0o644); err != nil {285+ t.Fatal(err)286+ }287+ cache := OpenCache(filepath.Join(root, "cache"))288+ cache.now = func() time.Time { return time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC) }289+ request := Request{290+ Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson",291+ Locator: source, IdentityLocator: "sample-region.geojson", MediaType: "application/geo+json",292+ }293+ request.ID = requestID(request)294+ capture, _, err := cache.Acquire(context.Background(), request, false)295+ if err != nil {296+ t.Fatal(err)297+ }298+ return cache, request, capture299+}300+301+func writeCapture(t *testing.T, cache *Cache, requestID, bodySHA string, capture Capture) {302+ t.Helper()303+ data, err := json.Marshal(capture)304+ if err != nil {305+ t.Fatal(err)306+ }307+ writeCaptureFile(t, cache, requestID, bodySHA, append(data, '\n'))308+}309+310+func writeCaptureFile(t *testing.T, cache *Cache, requestID, bodySHA string, data []byte) {311+ t.Helper()312+ if err := os.WriteFile(captureFilePath(cache, requestID, bodySHA), data, 0o644); err != nil {313+ t.Fatal(err)314+ }315+}316+317+func captureFilePath(cache *Cache, requestID, bodySHA string) string {318+ return filepath.Join(cache.Root(), "captures", requestID[:2], requestID, bodySHA+".json")319+}
A internal/authoring/evidence.go +139 -0
@@ -0,0 +1,139 @@
1+package authoring2+3+import (4+ "encoding/json"5+ "fmt"6+ "os"7+ "path/filepath"8+ "sort"9+10+ "github.com/FelineStateMachine/atlas/format/vnext"11+)12+13+const BuildReceiptFormat = "atlas-build-receipt/v2"14+15+// EvidenceSelection is the executable replay lock embedded in every authored16+// Atlas. It identifies the exact immutable captures selected for a build; it17+// deliberately contains no adapter or mapping output.18+type EvidenceSelection struct {19+ Format string `json:"format"`20+ Project string `json:"project"`21+ ProjectDigest string `json:"projectDigest"`22+ Captures []SelectedCapture `json:"captures"`23+}24+25+type SelectedCapture struct {26+ Request string `json:"request"`27+ Acquisition string `json:"acquisition"`28+ Source string `json:"source"`29+ SHA256 string `json:"sha256"`30+ Length int64 `json:"length"`31+ MediaType string `json:"mediaType"`32+ CapturedAt string `json:"capturedAt"`33+}34+35+func buildReceipt(plan Plan, captures map[string]Capture) ([]byte, string, error) {36+ selection := EvidenceSelection{37+ Format: BuildReceiptFormat, Project: plan.Project, ProjectDigest: plan.ProjectDigest,38+ }39+ createdAt := ""40+ for _, request := range plan.Requests {41+ capture, ok := captures[request.ID]42+ if !ok {43+ return nil, "", fmt.Errorf("build selects no capture for request %s", request.ID)44+ }45+ selection.Captures = append(selection.Captures, SelectedCapture{46+ Request: request.ID, Acquisition: requestCacheKey(request.Request), Source: request.Source, SHA256: capture.Body.SHA256,47+ Length: capture.Body.Length, MediaType: capture.Body.MediaType, CapturedAt: capture.CapturedAt,48+ })49+ if capture.CapturedAt > createdAt {50+ createdAt = capture.CapturedAt51+ }52+ }53+ if createdAt == "" {54+ return nil, "", fmt.Errorf("build selects no captured evidence")55+ }56+ sort.Slice(selection.Captures, func(i, j int) bool {57+ if selection.Captures[i].Request != selection.Captures[j].Request {58+ return selection.Captures[i].Request < selection.Captures[j].Request59+ }60+ return selection.Captures[i].Source < selection.Captures[j].Source61+ })62+ data, err := json.Marshal(selection)63+ if err != nil {64+ return nil, "", err65+ }66+ return append(data, '\n'), createdAt, nil67+}68+69+func loadEvidenceSelection(path string) (EvidenceSelection, error) {70+ if filepath.Ext(path) == ".atlas" {71+ file, err := vnext.OpenFile(path, vnext.StandardSchema())72+ if err != nil {73+ return EvidenceSelection{}, fmt.Errorf("open replay Atlas: %w", err)74+ }75+ defer file.Close()76+ volume, err := file.Volume()77+ if err != nil {78+ return EvidenceSelection{}, fmt.Errorf("read replay Atlas: %w", err)79+ }80+ for _, asset := range volume.Assets {81+ if asset.ID == "build-receipt" {82+ return decodeEvidenceSelection(asset.Data)83+ }84+ }85+ return EvidenceSelection{}, fmt.Errorf("replay Atlas has no build receipt")86+ }87+ data, err := os.ReadFile(path)88+ if err != nil {89+ return EvidenceSelection{}, fmt.Errorf("read replay receipt: %w", err)90+ }91+ return decodeEvidenceSelection(data)92+}93+94+func decodeEvidenceSelection(data []byte) (EvidenceSelection, error) {95+ var selection EvidenceSelection96+ if err := json.Unmarshal(data, &selection); err != nil {97+ return EvidenceSelection{}, fmt.Errorf("decode replay receipt: %w", err)98+ }99+ if selection.Format != BuildReceiptFormat || selection.Project == "" || len(selection.Captures) == 0 {100+ return EvidenceSelection{}, fmt.Errorf("invalid replay receipt")101+ }102+ seen := make(map[string]bool, len(selection.Captures))103+ for _, capture := range selection.Captures {104+ if capture.Request == "" || capture.Acquisition == "" || capture.Source == "" || capture.SHA256 == "" || capture.Length < 0 || capture.MediaType == "" || capture.CapturedAt == "" || seen[capture.Request] {105+ return EvidenceSelection{}, fmt.Errorf("invalid replay capture for request %s", capture.Request)106+ }107+ seen[capture.Request] = true108+ }109+ return selection, nil110+}111+112+func replayCaptures(plan Plan, selection EvidenceSelection, cache *Cache) (map[string]Capture, error) {113+ if selection.Project != plan.Project {114+ return nil, fmt.Errorf("replay receipt belongs to project %s, not %s", selection.Project, plan.Project)115+ }116+ selected := make(map[string]SelectedCapture, len(selection.Captures))117+ for _, capture := range selection.Captures {118+ selected[capture.Request] = capture119+ }120+ if len(selected) != len(plan.Requests) {121+ return nil, fmt.Errorf("replay receipt selects %d requests, project plans %d", len(selected), len(plan.Requests))122+ }123+ captures := make(map[string]Capture, len(plan.Requests))124+ for _, request := range plan.Requests {125+ want, ok := selected[request.ID]126+ if !ok || want.Source != request.Source || want.Acquisition != requestCacheKey(request.Request) {127+ return nil, fmt.Errorf("replay receipt does not select source %s request %s", request.Source, request.ID)128+ }129+ capture, err := cache.Select(want.Acquisition, want.SHA256)130+ if err != nil {131+ return nil, fmt.Errorf("select replay capture for %s: %w", request.Source, err)132+ }133+ if capture.Body.Length != want.Length || capture.Body.MediaType != want.MediaType || capture.CapturedAt != want.CapturedAt {134+ return nil, fmt.Errorf("replay capture metadata differs for source %s", request.Source)135+ }136+ captures[request.ID] = capture137+ }138+ return captures, nil139+}
A internal/authoring/evidence_test.go +106 -0
@@ -0,0 +1,106 @@
1+package authoring2+3+import (4+ "bytes"5+ "context"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+)11+12+func TestBuildReplaysExactEvidenceFromEarlierAtlas(t *testing.T) {13+ projectDir := t.TempDir()14+ for _, name := range []string{15+ "sample-region.atlas-project",16+ "sample-region.geojson",17+ "sample-region-routes.geojson",18+ "sample-region-areas.geojson",19+ "sample-region-raster.ppm",20+ "sample-region-symbol.svg",21+ } {22+ data, err := os.ReadFile(filepath.Join("..", "..", "examples", name))23+ if err != nil {24+ t.Fatal(err)25+ }26+ if err := os.WriteFile(filepath.Join(projectDir, name), data, 0o644); err != nil {27+ t.Fatal(err)28+ }29+ }30+ projectPath := filepath.Join(projectDir, "sample-region.atlas-project")31+ cacheDir := filepath.Join(t.TempDir(), "cache")32+ libraryDir := filepath.Join(t.TempDir(), "library")33+ first, err := Build(context.Background(), BuildOptions{34+ ProjectPath: projectPath, CacheDir: cacheDir, LibraryDir: libraryDir,35+ })36+ if err != nil {37+ t.Fatal(err)38+ }39+ firstBytes, err := os.ReadFile(first.Path)40+ if err != nil {41+ t.Fatal(err)42+ }43+44+ featurePath := filepath.Join(projectDir, "sample-region.geojson")45+ changed, err := os.ReadFile(featurePath)46+ if err != nil {47+ t.Fatal(err)48+ }49+ changed = []byte(strings.Replace(string(changed), "Sample Marker", "Updated Marker", 1))50+ if err := os.WriteFile(featurePath, changed, 0o644); err != nil {51+ t.Fatal(err)52+ }53+ second, err := Build(context.Background(), BuildOptions{54+ ProjectPath: projectPath, CacheDir: cacheDir, LibraryDir: libraryDir,55+ })56+ if err != nil {57+ t.Fatal(err)58+ }59+ if second.Path == first.Path {60+ t.Fatal("changed evidence did not produce a distinct Atlas build")61+ }62+63+ replayed, err := Build(context.Background(), BuildOptions{64+ ProjectPath: projectPath, CacheDir: cacheDir, LibraryDir: libraryDir,65+ ReplayPath: first.Path,66+ })67+ if err != nil {68+ t.Fatal(err)69+ }70+ replayedBytes, err := os.ReadFile(replayed.Path)71+ if err != nil {72+ t.Fatal(err)73+ }74+ if replayed.Path != first.Path || !replayed.Present || !bytes.Equal(replayedBytes, firstBytes) {75+ t.Fatalf("exact replay differs: first=%s replayed=%s present=%v", first.Path, replayed.Path, replayed.Present)76+ }77+}78+79+func TestReplayAllowsMappingOnlyProjectChangeButRejectsAcquisitionDrift(t *testing.T) {80+ request := PlannedRequest{Request: Request{81+ ID: strings.Repeat("a", 64), Kind: RequestFeatures, Source: "sample-places",82+ }}83+ plan := Plan{Project: "sample-region", ProjectDigest: "new-policy", Requests: []PlannedRequest{request}}84+ selection := EvidenceSelection{85+ Format: BuildReceiptFormat, Project: "sample-region", ProjectDigest: "old-policy",86+ Captures: []SelectedCapture{{87+ Request: request.ID, Acquisition: request.ID, Source: request.Source, SHA256: strings.Repeat("b", 64),88+ Length: 1, MediaType: "application/geo+json", CapturedAt: "2026-08-08T12:00:00Z",89+ }},90+ }91+ data, _, err := buildReceipt(plan, map[string]Capture{request.ID: {92+ RequestHash: request.ID, Source: request.Source, CapturedAt: selection.Captures[0].CapturedAt,93+ Body: BlobRef{SHA256: selection.Captures[0].SHA256, Length: 1, MediaType: selection.Captures[0].MediaType},94+ }})95+ if err != nil {96+ t.Fatal(err)97+ }98+ decoded, err := decodeEvidenceSelection(data)99+ if err != nil || decoded.ProjectDigest != "new-policy" {100+ t.Fatalf("current policy receipt = %+v, %v", decoded, err)101+ }102+ selection.Captures[0].Request = strings.Repeat("c", 64)103+ if _, err := replayCaptures(plan, selection, OpenCache(t.TempDir())); err == nil || !strings.Contains(err.Error(), "does not select") {104+ t.Fatalf("replay acquisition drift = %v", err)105+ }106+}
M internal/authoring/manifest.go +202 -93
@@ -25,16 +25,17 @@ const (
2525 2626 // Project is the complete durable input to one Atlas build.2727 type Project struct {28- Schema string `yaml:"schema"`29- SchemaNamespace string `yaml:"schema-namespace"`30- ID string `yaml:"id"`31- Title string `yaml:"title"`32- Target Target `yaml:"target"`33- Sources []Source `yaml:"sources,omitempty"`34- Rasters []Raster `yaml:"rasters,omitempty"`35- Assets []Asset `yaml:"assets,omitempty"`36- Presentation Presentation `yaml:"presentation"`37- Release Release `yaml:"release,omitempty"`28+ Schema string `yaml:"schema"`29+ SchemaNamespace string `yaml:"schema-namespace"`30+ ID string `yaml:"id"`31+ Title string `yaml:"title"`32+ Target Target `yaml:"target"`33+ FeatureSets []FeatureSetContract `yaml:"feature-sets,omitempty"`34+ Sources []Source `yaml:"sources,omitempty"`35+ Rasters []Raster `yaml:"rasters,omitempty"`36+ Assets []Asset `yaml:"assets,omitempty"`37+ Presentation Presentation `yaml:"presentation"`38+ Release Release `yaml:"release,omitempty"`3839 3940 baseDir string4041 }
@@ -81,16 +82,39 @@ type Query struct {
8182 Limit int `yaml:"limit,omitempty"`8283 }8384 84-// Mapping turns source observations into one semantic feature set. Sources85-// that name the same feature-set and canonical identity meet during assembly.85+// FeatureSetContract is the source-independent semantic and geometry contract86+// populated by every mapping that targets its ID.87+type FeatureSetContract struct {88+ ID string `yaml:"id"`89+ Title string `yaml:"title"`90+ SemanticType string `yaml:"semantic-type"`91+ Geometry string `yaml:"geometry"`92+ Properties []PropertyContract `yaml:"properties,omitempty"`93+ Relationships []RelationshipContract `yaml:"relationships,omitempty"`94+}95+96+type PropertyContract struct {97+ ID string `yaml:"id"`98+ Name string `yaml:"name"`99+ Type string `yaml:"type"`100+ Optional bool `yaml:"optional,omitempty"`101+}102+103+type RelationshipContract struct {104+ ID string `yaml:"id"`105+ Predicate string `yaml:"predicate"`106+ FeatureSet string `yaml:"feature-set"`107+ Optional bool `yaml:"optional,omitempty"`108+}109+110+// Mapping binds source paths to one declared FeatureSet contract. Sources that111+// name the same feature-set and canonical identity meet during assembly.86112 type Mapping struct {87113 FeatureSet string `yaml:"feature-set"`88- Title string `yaml:"title"`89- SemanticType string `yaml:"semantic-type"`90114 Identity string `yaml:"identity"`91115 FeatureTitle string `yaml:"feature-title"`92116 Geometry GeometryMap `yaml:"geometry"`93- Fields []Field `yaml:"fields,omitempty"`117+ Properties []PropertyMap `yaml:"properties,omitempty"`94118 Relations []RelationMap `yaml:"relationships,omitempty"`95119 }96120
@@ -98,7 +122,7 @@ type Mapping struct {
98122 // Atlas intentionally executes only explicit identity and affine transforms;99123 // a CRS label alone never implies executable projection behavior.100124 type GeometryMap struct {101- Family string `yaml:"family"`125+ Family string `yaml:"-" json:"-"`102126 SourceSpace string `yaml:"source-space"`103127 Transform CoordinateTransform `yaml:"transform"`104128 }
@@ -108,19 +132,14 @@ type CoordinateTransform struct {
108132 Matrix [6]float64 `yaml:"matrix,omitempty"`109133 }110134 111-type Field struct {112- ID string `yaml:"id"`113- Name string `yaml:"name,omitempty"`114- Source string `yaml:"source"`115- Type string `yaml:"type"`116- Optional bool `yaml:"optional,omitempty"`135+type PropertyMap struct {136+ Field string `yaml:"field"`137+ Source string `yaml:"source"`117138 }118139 119140 type RelationMap struct {120- Predicate string `yaml:"predicate"`121- FeatureSet string `yaml:"feature-set,omitempty"`122- Target string `yaml:"target"`123- Optional bool `yaml:"optional,omitempty"`141+ Relationship string `yaml:"relationship"`142+ Target string `yaml:"target"`124143 }125144 126145 // Raster is a configured raster adapter. A file is deterministically expanded
@@ -267,34 +286,19 @@ func (project Project) Validate() error {
267286 if len(project.Sources) == 0 && len(project.Rasters) == 0 {268287 return fmt.Errorf("project %s configures no sources or rasters", project.ID)269288 }270- sets := make(map[string]Mapping)289+ sets, err := validateFeatureSetContracts(project.FeatureSets)290+ if err != nil {291+ return err292+ }271293 sourceIDs := make(map[string]bool)272294 for _, source := range project.Sources {273- if err := validateSource(source, project.Target.CoordinateSpace); err != nil {295+ if err := validateSource(source, project.Target.CoordinateSpace, sets); err != nil {274296 return err275297 }276298 if sourceIDs[source.ID] {277299 return fmt.Errorf("source %s is configured twice", source.ID)278300 }279301 sourceIDs[source.ID] = true280- if held, ok := sets[source.Mapping.FeatureSet]; ok {281- if err := compatibleMapping(held, source.Mapping); err != nil {282- return fmt.Errorf("feature set %s has conflicting declarations", source.Mapping.FeatureSet)283- }284- } else {285- sets[source.Mapping.FeatureSet] = source.Mapping286- }287- }288- for _, source := range project.Sources {289- for _, relation := range source.Mapping.Relations {290- target := relation.FeatureSet291- if target == "" {292- target = source.Mapping.FeatureSet293- }294- if _, ok := sets[target]; !ok {295- return fmt.Errorf("source %s relationship %s targets unknown feature set %s", source.ID, relation.Predicate, target)296- }297- }298302 }299303 rasterIDs := make(map[string]bool)300304 for _, raster := range project.Rasters {
@@ -336,7 +340,82 @@ func validateTarget(target Target) error {
336340 return nil337341 }338342 339-func validateSource(source Source, target CoordinateSpace) error {343+func validateFeatureSetContracts(values []FeatureSetContract) (map[string]FeatureSetContract, error) {344+ sets := make(map[string]FeatureSetContract, len(values))345+ for _, set := range values {346+ if err := validateFeatureSetContract(set); err != nil {347+ return nil, err348+ }349+ if _, exists := sets[set.ID]; exists {350+ return nil, fmt.Errorf("feature set %s is configured twice", set.ID)351+ }352+ sets[set.ID] = set353+ }354+ for _, set := range values {355+ for _, relation := range set.Relationships {356+ if _, exists := sets[relation.FeatureSet]; !exists {357+ return nil, fmt.Errorf("feature set %s relationship %s targets unknown feature set %s", set.ID, relation.ID, relation.FeatureSet)358+ }359+ }360+ }361+ return sets, nil362+}363+364+func validateFeatureSetContract(set FeatureSetContract) error {365+ if err := vnext.ValidSlug(set.ID); err != nil {366+ return fmt.Errorf("feature set identity: %w", err)367+ }368+ if set.Title == "" || set.SemanticType == "" {369+ return fmt.Errorf("feature set %s requires a title and semantic type", set.ID)370+ }371+ if err := validateGeometryFamily(set.Geometry); err != nil {372+ return fmt.Errorf("feature set %s: %w", set.ID, err)373+ }374+ properties := make(map[string]bool, len(set.Properties))375+ for _, property := range set.Properties {376+ if property.ID == "" || property.Name == "" || property.Type == "" {377+ return fmt.Errorf("feature set %s has an incomplete property contract", set.ID)378+ }379+ if _, err := kindOf(property.Type); err != nil {380+ return fmt.Errorf("feature set %s property %s: %w", set.ID, property.ID, err)381+ }382+ if properties[property.ID] {383+ return fmt.Errorf("feature set %s declares property %s twice", set.ID, property.ID)384+ }385+ properties[property.ID] = true386+ }387+ return validateRelationshipContracts(set)388+}389+390+func validateRelationshipContracts(set FeatureSetContract) error {391+ identities := make(map[string]bool, len(set.Relationships))392+ semantics := make(map[string]bool, len(set.Relationships))393+ for _, relation := range set.Relationships {394+ if err := vnext.ValidSlug(relation.ID); err != nil {395+ return fmt.Errorf("feature set %s relationship identity: %w", set.ID, err)396+ }397+ if relation.Predicate == "" || relation.FeatureSet == "" {398+ return fmt.Errorf("feature set %s has an incomplete relationship contract", set.ID)399+ }400+ semantic := relation.Predicate + "\x00" + relation.FeatureSet401+ if identities[relation.ID] || semantics[semantic] {402+ return fmt.Errorf("feature set %s declares relationship %s twice", set.ID, relation.ID)403+ }404+ identities[relation.ID], semantics[semantic] = true, true405+ }406+ return nil407+}408+409+func validateGeometryFamily(family string) error {410+ switch family {411+ case "point", "path", "area":412+ return nil413+ default:414+ return fmt.Errorf("unknown geometry family %q", family)415+ }416+}417+418+func validateSource(source Source, target CoordinateSpace, sets map[string]FeatureSetContract) error {340419 if err := vnext.ValidSlug(source.ID); err != nil {341420 return fmt.Errorf("source identity: %w", err)342421 }
@@ -347,42 +426,46 @@ func validateSource(source Source, target CoordinateSpace) error {
347426 return fmt.Errorf("source %s has a negative size estimate", source.ID)348427 }349428 mapping := source.Mapping350- if err := vnext.ValidSlug(mapping.FeatureSet); err != nil {351- return fmt.Errorf("source %s feature set: %w", source.ID, err)429+ set, exists := sets[mapping.FeatureSet]430+ if !exists {431+ return fmt.Errorf("source %s targets unknown feature set %s", source.ID, mapping.FeatureSet)352432 }353- if mapping.Title == "" || mapping.SemanticType == "" || mapping.Identity == "" || mapping.FeatureTitle == "" {433+ if mapping.Identity == "" || mapping.FeatureTitle == "" {354434 return fmt.Errorf("source %s has an incomplete mapping", source.ID)355435 }356436 if err := validateGeometryMap(mapping.Geometry, target); err != nil {357437 return fmt.Errorf("source %s geometry: %w", source.ID, err)358438 }359- fields := make(map[string]string)360- for _, field := range mapping.Fields {361- if field.ID == "" || field.Source == "" || field.Type == "" {362- return fmt.Errorf("source %s has an incomplete field mapping", source.ID)439+ properties := make(map[string]bool, len(mapping.Properties))440+ for _, property := range mapping.Properties {441+ if property.Field == "" || property.Source == "" {442+ return fmt.Errorf("source %s has an incomplete property mapping", source.ID)363443 }364- if _, err := kindOf(field.Type); err != nil {365- return fmt.Errorf("source %s field %s: %w", source.ID, field.ID, err)444+ if _, exists := propertyContract(set, property.Field); !exists {445+ return fmt.Errorf("source %s maps unknown property %s", source.ID, property.Field)366446 }367- if held := fields[field.ID]; held != "" {368- return fmt.Errorf("source %s maps field %s twice", source.ID, field.ID)447+ if properties[property.Field] {448+ return fmt.Errorf("source %s maps property %s twice", source.ID, property.Field)369449 }370- fields[field.ID] = field.Type450+ properties[property.Field] = true371451 }452+ relations := make(map[string]bool, len(mapping.Relations))372453 for _, relation := range mapping.Relations {373- if relation.Predicate == "" || relation.Target == "" {454+ if relation.Relationship == "" || relation.Target == "" {374455 return fmt.Errorf("source %s has an incomplete relationship mapping", source.ID)375456 }457+ if _, exists := relationshipContract(set, relation.Relationship); !exists {458+ return fmt.Errorf("source %s maps unknown relationship %s", source.ID, relation.Relationship)459+ }460+ if relations[relation.Relationship] {461+ return fmt.Errorf("source %s maps relationship %s twice", source.ID, relation.Relationship)462+ }463+ relations[relation.Relationship] = true376464 }377465 return nil378466 }379467 380468 func validateGeometryMap(mapping GeometryMap, target CoordinateSpace) error {381- switch mapping.Family {382- case "point", "path", "area":383- default:384- return fmt.Errorf("unknown geometry family %q", mapping.Family)385- }386469 if strings.TrimSpace(mapping.SourceSpace) == "" {387470 return fmt.Errorf("geometry has no source space")388471 }
@@ -407,22 +490,6 @@ func validateGeometryMap(mapping GeometryMap, target CoordinateSpace) error {
407490 return nil408491 }409492 410-func compatibleMapping(left, right Mapping) error {411- if left.Title != right.Title || left.SemanticType != right.SemanticType || left.Geometry.Family != right.Geometry.Family {412- return fmt.Errorf("feature set metadata differs")413- }414- fields := make(map[string]Field, len(left.Fields))415- for _, field := range left.Fields {416- fields[field.ID] = field417- }418- for _, field := range right.Fields {419- if held, ok := fields[field.ID]; ok && (held.Name != field.Name || held.Type != field.Type || held.Optional != field.Optional) {420- return fmt.Errorf("field %s contract differs", field.ID)421- }422- }423- return nil424-}425-426493 func validateRaster(raster Raster) error {427494 if err := vnext.ValidSlug(raster.ID); err != nil {428495 return fmt.Errorf("raster identity: %w", err)
@@ -445,16 +512,15 @@ func validateRaster(raster Raster) error {
445512 if len(raster.Levels) == 0 || !strings.Contains(raster.Locator, "{z}") || !strings.Contains(raster.Locator, "{x}") || !strings.Contains(raster.Locator, "{y}") {446513 return fmt.Errorf("xyz raster %s requires a template and explicit levels", raster.ID)447514 }448- previous := int64(-1)449- for _, level := range raster.Levels {450- if level.Zoom != previous+1 || level.Zoom < 0 || level.Zoom > 30 {451- return fmt.Errorf("xyz raster %s has an invalid or unordered level", raster.ID)452- }453- maximum := int64(1)<<level.Zoom - 1454- if level.MinX < 0 || level.MinY < 0 || level.MaxX < level.MinX || level.MaxY < level.MinY || level.MaxX > maximum || level.MaxY > maximum {455- return fmt.Errorf("xyz raster %s has an invalid or unordered level", raster.ID)456- }457- previous = level.Zoom515+ if err := validateRasterLevels(raster); err != nil {516+ return err517+ }518+ case "wmts":519+ if len(raster.Levels) == 0 || !strings.Contains(raster.Locator, "{TileMatrix}") || !strings.Contains(raster.Locator, "{TileCol}") || !strings.Contains(raster.Locator, "{TileRow}") {520+ return fmt.Errorf("wmts raster %s requires a REST template and explicit levels", raster.ID)521+ }522+ if err := validateRasterLevels(raster); err != nil {523+ return err458524 }459525 default:460526 return fmt.Errorf("raster %s uses unknown adapter %q", raster.ID, raster.Adapter)
@@ -462,6 +528,21 @@ func validateRaster(raster Raster) error {
462528 return nil463529 }464530 531+func validateRasterLevels(raster Raster) error {532+ previous := int64(-1)533+ for _, level := range raster.Levels {534+ if level.Zoom != previous+1 || level.Zoom < 0 || level.Zoom > 30 {535+ return fmt.Errorf("%s raster %s has an invalid or unordered level", raster.Adapter, raster.ID)536+ }537+ maximum := int64(1)<<level.Zoom - 1538+ if level.MinX < 0 || level.MinY < 0 || level.MaxX < level.MinX || level.MaxY < level.MinY || level.MaxX > maximum || level.MaxY > maximum {539+ return fmt.Errorf("%s raster %s has an invalid or unordered level", raster.Adapter, raster.ID)540+ }541+ previous = level.Zoom542+ }543+ return nil544+}545+465546 func validRasterRect(rect RasterRect) bool {466547 return finite(rect.X) && finite(rect.Y) && finite(rect.Width) && finite(rect.Height) && rect.Width > 0 && rect.Height > 0467548 }
@@ -481,7 +562,7 @@ func validateAsset(asset Asset) error {
481562 return nil482563 }483564 484-func validatePresentation(presentation Presentation, sets map[string]Mapping, assets map[string]bool) error {565+func validatePresentation(presentation Presentation, sets map[string]FeatureSetContract, assets map[string]bool) error {485566 if presentation.ID == "" || presentation.Title == "" {486567 return fmt.Errorf("presentation requires an ID and title")487568 }
@@ -497,7 +578,8 @@ func validatePresentation(presentation Presentation, sets map[string]Mapping, as
497578 }498579 layers := make(map[string]bool)499580 for _, layer := range presentation.Layers {500- if layer.ID == "" || layers[layer.ID] || sets[layer.FeatureSet].FeatureSet == "" || !styles[layer.Style] || layer.Label == "" || layer.MaxZoom < layer.MinZoom {581+ _, knownSet := sets[layer.FeatureSet]582+ if layer.ID == "" || layers[layer.ID] || !knownSet || !styles[layer.Style] || layer.Label == "" || layer.MaxZoom < layer.MinZoom {501583 return fmt.Errorf("presentation layer %s is invalid", layer.ID)502584 }503585 layers[layer.ID] = true
@@ -505,6 +587,33 @@ func validatePresentation(presentation Presentation, sets map[string]Mapping, as
505587 return nil506588 }507589 590+func propertyContract(set FeatureSetContract, id string) (PropertyContract, bool) {591+ for _, property := range set.Properties {592+ if property.ID == id {593+ return property, true594+ }595+ }596+ return PropertyContract{}, false597+}598+599+func featureSetContract(sets []FeatureSetContract, id string) (FeatureSetContract, bool) {600+ for _, set := range sets {601+ if set.ID == id {602+ return set, true603+ }604+ }605+ return FeatureSetContract{}, false606+}607+608+func relationshipContract(set FeatureSetContract, id string) (RelationshipContract, bool) {609+ for _, relation := range set.Relationships {610+ if relation.ID == id {611+ return relation, true612+ }613+ }614+ return RelationshipContract{}, false615+}616+508617 func kindOf(name string) (vnext.Kind, error) {509618 switch name {510619 case "bool":
M internal/authoring/manifest_test.go +96 -15
@@ -22,6 +22,15 @@ target:
2222 extent: [0, 0, 256, 256]2323 tile-size: 2562424 size: 25625+feature-sets:26+ - id: places27+ title: Places28+ semantic-type: sample.place29+ geometry: point30+ properties:31+ - id: sample.kind32+ name: Kind33+ type: string2534 sources:2635 - id: sample-features2736 adapter: geojson
@@ -30,19 +39,15 @@ sources:
3039 attribution: Sample publisher3140 mapping:3241 feature-set: places33- title: Places34- semantic-type: sample.place3542 identity: properties.id3643 feature-title: properties.name3744 geometry:38- family: point3945 source-space: sample-region/space4046 transform:4147 kind: identity42- fields:43- - id: sample.kind48+ properties:49+ - field: sample.kind4450 source: properties.kind45- type: string4651 presentation:4752 id: sample-region/presentation4853 title: Sample Region
@@ -101,17 +106,14 @@ func TestProjectRequiresManifestExtension(t *testing.T) {
101106 }102107 }103108 104-func TestProjectRejectsConflictingFeatureSets(t *testing.T) {109+func TestProjectRejectsUnknownFeatureSetBinding(t *testing.T) {105110 project, err := LoadProject(writeSampleProject(t, sampleProject))106111 if err != nil {107112 t.Fatal(err)108113 }109- second := project.Sources[0]110- second.ID = "sample-features-two"111- second.Mapping.Title = "Different title"112- project.Sources = append(project.Sources, second)113- if err := project.Validate(); err == nil || !strings.Contains(err.Error(), "conflicting declarations") {114- t.Fatalf("Validate = %v, want feature-set conflict", err)114+ project.Sources[0].Mapping.FeatureSet = "missing"115+ if err := project.Validate(); err == nil || !strings.Contains(err.Error(), "unknown feature set") {116+ t.Fatalf("Validate = %v, want unknown feature-set binding", err)115117 }116118 }117119
@@ -124,7 +126,7 @@ func TestProjectRejectsGeometryAndRelationshipContractErrors(t *testing.T) {
124126 {125127 name: "unknown geometry family",126128 change: func(project *Project) {127- project.Sources[0].Mapping.Geometry.Family = "mesh"129+ project.FeatureSets[0].Geometry = "mesh"128130 },129131 mention: "unknown geometry family",130132 },
@@ -146,7 +148,7 @@ func TestProjectRejectsGeometryAndRelationshipContractErrors(t *testing.T) {
146148 {147149 name: "unknown relationship feature set",148150 change: func(project *Project) {149- project.Sources[0].Mapping.Relations = []RelationMap{{Predicate: "within", FeatureSet: "missing", Target: "properties.parent"}}151+ project.FeatureSets[0].Relationships = []RelationshipContract{{ID: "area", Predicate: "within", FeatureSet: "missing"}}150152 },151153 mention: "unknown feature set",152154 },
@@ -172,6 +174,85 @@ func TestProjectRejectsGeometryAndRelationshipContractErrors(t *testing.T) {
172174 }173175 }174176 177+func TestProjectRejectsInvalidFeatureSetContractsAndBindings(t *testing.T) {178+ tests := []struct {179+ name string180+ change func(*Project)181+ mention string182+ }{183+ {184+ name: "duplicate feature set",185+ change: func(project *Project) {186+ project.FeatureSets = append(project.FeatureSets, project.FeatureSets[0])187+ },188+ mention: "configured twice",189+ },190+ {191+ name: "duplicate property",192+ change: func(project *Project) {193+ project.FeatureSets[0].Properties = append(project.FeatureSets[0].Properties, project.FeatureSets[0].Properties[0])194+ },195+ mention: "property sample.kind twice",196+ },197+ {198+ name: "unknown mapped property",199+ change: func(project *Project) {200+ project.Sources[0].Mapping.Properties[0].Field = "missing"201+ },202+ mention: "unknown property missing",203+ },204+ {205+ name: "duplicate mapped property",206+ change: func(project *Project) {207+ project.Sources[0].Mapping.Properties = append(project.Sources[0].Mapping.Properties, project.Sources[0].Mapping.Properties[0])208+ },209+ mention: "maps property sample.kind twice",210+ },211+ {212+ name: "unknown mapped relationship",213+ change: func(project *Project) {214+ project.Sources[0].Mapping.Relations = []RelationMap{{Relationship: "missing", Target: "properties.area"}}215+ },216+ mention: "unknown relationship missing",217+ },218+ }219+ for _, test := range tests {220+ t.Run(test.name, func(t *testing.T) {221+ project, err := LoadProject(writeSampleProject(t, sampleProject))222+ if err != nil {223+ t.Fatal(err)224+ }225+ test.change(&project)226+ if err := project.Validate(); err == nil || !strings.Contains(err.Error(), test.mention) {227+ t.Fatalf("Validate = %v, want %q", err, test.mention)228+ }229+ })230+ }231+}232+233+func TestProjectAllowsDeclaredFeatureSetWithoutASource(t *testing.T) {234+ project, err := LoadProject(writeSampleProject(t, sampleProject))235+ if err != nil {236+ t.Fatal(err)237+ }238+ project.FeatureSets = append(project.FeatureSets, FeatureSetContract{239+ ID: "areas", Title: "Sample Areas", SemanticType: "region", Geometry: "area",240+ })241+ project.Presentation.Layers = append(project.Presentation.Layers, Layer{242+ ID: "areas-layer", FeatureSet: "areas", Style: "place-style", Label: "Sample Areas", Visible: true, MaxZoom: 32,243+ })244+ if err := project.Validate(); err != nil {245+ t.Fatalf("Validate = %v, want declared empty set", err)246+ }247+}248+249+func TestProjectRejectsInlineFeatureSetDeclarations(t *testing.T) {250+ legacy := strings.Replace(sampleProject, " identity: properties.id\n", " title: Places\n identity: properties.id\n", 1)251+ if _, err := LoadProject(writeSampleProject(t, legacy)); err == nil || !strings.Contains(err.Error(), "field title not found") {252+ t.Fatalf("LoadProject = %v, want strict inline-contract refusal", err)253+ }254+}255+175256 func TestProjectV1IsAHardBreak(t *testing.T) {176257 legacy := strings.Replace(sampleProject, ProjectSchema, "atlas-project/v1", 1)177258 if _, err := LoadProject(writeSampleProject(t, legacy)); err == nil || !strings.Contains(err.Error(), ProjectSchema) {
M internal/authoring/observe.go +60 -34
@@ -18,26 +18,32 @@ type observation struct {
1818 Source string1919 NativeID string2020 FeatureSet string21- SetTitle string22- Semantic string23- Family string2421 Title string2522 Geometry vnext.Geometry26- Fields map[string]vnext.Field2723 Values map[string]vnext.Value2824 Relations []observedRelation2925 Evidence Capture3026 }3127 3228 type observedRelation struct {29+ Contract string3330 Predicate string3431 FeatureSet string3532 NativeID string3633 }3734 3835 type geoJSON struct {39- Type string `json:"type"`40- Features []geoJSONFeature `json:"features"`36+ Type string `json:"type"`37+ Features []geoJSONFeature `json:"features"`38+ Links []geoJSONLink `json:"links,omitempty"`39+ NumberMatched *int64 `json:"numberMatched,omitempty"`40+ NumberReturned *int64 `json:"numberReturned,omitempty"`41+ ExceededTransferLimit bool `json:"exceededTransferLimit,omitempty"`42+}43+44+type geoJSONLink struct {45+ Rel string `json:"rel"`46+ Href string `json:"href"`4147 }4248 4349 type geoJSONFeature struct {
@@ -53,6 +59,10 @@ type geoJSONGeometry struct {
5359 }5460 5561 func observe(project Project, sourceOrder int, source Source, capture Capture, body []byte) ([]observation, error) {62+ set, exists := featureSetContract(project.FeatureSets, source.Mapping.FeatureSet)63+ if !exists {64+ return nil, fmt.Errorf("source %s targets unknown feature set %s", source.ID, source.Mapping.FeatureSet)65+ }5666 decoder := json.NewDecoder(bytes.NewReader(body))5767 decoder.UseNumber()5868 var collection geoJSON
@@ -62,6 +72,9 @@ func observe(project Project, sourceOrder int, source Source, capture Capture, b
6272 if collection.Type != "FeatureCollection" {6373 return nil, fmt.Errorf("source %s is %q, want FeatureCollection", source.ID, collection.Type)6474 }75+ if err := validateAdapterCompleteness(source, collection); err != nil {76+ return nil, err77+ }6578 out := make([]observation, 0, len(collection.Features))6679 for index, feature := range collection.Features {6780 if feature.Type != "" && feature.Type != "Feature" {
@@ -83,53 +96,47 @@ func observe(project Project, sourceOrder int, source Source, capture Capture, b
8396 if err != nil {8497 return nil, fmt.Errorf("source %s feature %s: %w", source.ID, nativeID, err)8598 }86- geometry, err = transformGeometry(geometry, source.Mapping.Geometry, project.Target.CoordinateSpace)99+ geometryMap := source.Mapping.Geometry100+ geometryMap.Family = set.Geometry101+ geometry, err = transformGeometry(geometry, geometryMap, project.Target.CoordinateSpace)87102 if err != nil {88103 return nil, fmt.Errorf("source %s feature %s: %w", source.ID, nativeID, err)89104 }90105 item := observation{91106 SourceOrder: sourceOrder, Source: source.ID, NativeID: nativeID,92- FeatureSet: source.Mapping.FeatureSet, SetTitle: source.Mapping.Title,93- Semantic: source.Mapping.SemanticType, Family: source.Mapping.Geometry.Family, Title: scalarString(titleValue),94- Geometry: geometry, Fields: make(map[string]vnext.Field), Values: make(map[string]vnext.Value),107+ FeatureSet: source.Mapping.FeatureSet, Title: scalarString(titleValue),108+ Geometry: geometry, Values: make(map[string]vnext.Value),95109 Evidence: capture,96110 }97- for _, mapping := range source.Mapping.Fields {111+ for _, mapping := range source.Mapping.Properties {98112 raw, held := featureValue(feature, mapping.Source)99113 if !held || raw == nil {100- if mapping.Optional {101- continue102- }103- return nil, fmt.Errorf("source %s feature %s omits required field %s", source.ID, nativeID, mapping.ID)114+ continue104115 }105- kind, _ := kindOf(mapping.Type)116+ contract, exists := propertyContract(set, mapping.Field)117+ if !exists {118+ return nil, fmt.Errorf("source %s maps unknown property %s", source.ID, mapping.Field)119+ }120+ kind, _ := kindOf(contract.Type)106121 value, err := valueOf(project.SchemaNamespace, kind, raw)107122 if err != nil {108- return nil, fmt.Errorf("source %s feature %s field %s: %w", source.ID, nativeID, mapping.ID, err)109- }110- name := mapping.Name111- if name == "" {112- name = mapping.ID113- }114- item.Fields[mapping.ID] = vnext.Field{115- ID: vnext.IDFromName(project.SchemaNamespace, "feature."+source.Mapping.FeatureSet+"."+mapping.ID),116- Name: name, Kind: kind, Optional: mapping.Optional,123+ return nil, fmt.Errorf("source %s feature %s property %s: %w", source.ID, nativeID, mapping.Field, err)117124 }118- item.Values[mapping.ID] = value125+ item.Values[mapping.Field] = value119126 }120127 for _, mapping := range source.Mapping.Relations {121128 raw, held := featureValue(feature, mapping.Target)122129 if !held || scalarString(raw) == "" {123- if mapping.Optional {124- continue125- }126- return nil, fmt.Errorf("source %s feature %s omits relationship %s", source.ID, nativeID, mapping.Predicate)130+ continue127131 }128- set := mapping.FeatureSet129- if set == "" {130- set = source.Mapping.FeatureSet132+ contract, exists := relationshipContract(set, mapping.Relationship)133+ if !exists {134+ return nil, fmt.Errorf("source %s maps unknown relationship %s", source.ID, mapping.Relationship)131135 }132- item.Relations = append(item.Relations, observedRelation{Predicate: mapping.Predicate, FeatureSet: set, NativeID: scalarString(raw)})136+ item.Relations = append(item.Relations, observedRelation{137+ Contract: mapping.Relationship, Predicate: contract.Predicate,138+ FeatureSet: contract.FeatureSet, NativeID: scalarString(raw),139+ })133140 }134141 out = append(out, item)135142 }
@@ -137,6 +144,25 @@ func observe(project Project, sourceOrder int, source Source, capture Capture, b
137144 return out, nil138145 }139146 147+func validateAdapterCompleteness(source Source, collection geoJSON) error {148+ switch source.Adapter {149+ case "arcgis-feature-service":150+ if collection.ExceededTransferLimit {151+ return fmt.Errorf("source %s ArcGIS response exceeded its transfer limit; narrow or partition the configured query", source.ID)152+ }153+ case "ogc-api-features":154+ if collection.NumberReturned != nil && *collection.NumberReturned != int64(len(collection.Features)) {155+ return fmt.Errorf("source %s OGC response count differs from its feature payload", source.ID)156+ }157+ for _, link := range collection.Links {158+ if strings.EqualFold(link.Rel, "next") && strings.TrimSpace(link.Href) != "" {159+ return fmt.Errorf("source %s OGC response has a next page; narrow or partition the configured query", source.ID)160+ }161+ }162+ }163+ return nil164+}165+140166 func featureValue(feature geoJSONFeature, path string) (any, bool) {141167 switch path {142168 case "id":
M internal/authoring/plan.go +61 -10
@@ -23,9 +23,11 @@ const (
2323 // Request is one exact acquisition planned without touching the network.2424 type Request struct {2525 ID string `json:"id"`26+ AcquisitionID string `json:"acquisition"`2627 Kind RequestKind `json:"kind"`2728 Source string `json:"source"`2829 Adapter string `json:"adapter"`30+ AdapterVersion string `json:"adapterVersion"`2931 Locator string `json:"-"`3032 IdentityLocator string `json:"locator"`3133 MediaType string `json:"mediaType,omitempty"`
@@ -78,7 +80,7 @@ func PlanProject(project Project, cache *Cache, output string) (Plan, error) {
7880 return Plan{}, err7981 }8082 planned := PlannedRequest{Request: request, EstimateBytes: source.EstimateBytes}81- planned.Cached = cache != nil && cache.Has(request.ID)83+ planned.Cached = cache != nil && cache.Has(requestCacheKey(request))8284 plan.Requests = append(plan.Requests, planned)8385 plan.Estimated += source.EstimateBytes8486 plan.Licenses = append(plan.Licenses, Obligation{Source: source.ID, License: source.License, Attribution: source.Attribution})
@@ -98,7 +100,7 @@ func PlanProject(project Project, cache *Cache, output string) (Plan, error) {
98100 estimate += raster.EstimateBytes - each*int64(len(requests))99101 }100102 planned := PlannedRequest{Request: request, EstimateBytes: estimate}101- planned.Cached = cache != nil && cache.Has(request.ID)103+ planned.Cached = cache != nil && cache.Has(requestCacheKey(request))102104 plan.Requests = append(plan.Requests, planned)103105 }104106 plan.Estimated += raster.EstimateBytes
@@ -107,7 +109,7 @@ func PlanProject(project Project, cache *Cache, output string) (Plan, error) {
107109 for _, asset := range project.Assets {108110 request := assetRequest(project, asset)109111 planned := PlannedRequest{Request: request, EstimateBytes: asset.EstimateBytes}110- planned.Cached = cache != nil && cache.Has(request.ID)112+ planned.Cached = cache != nil && cache.Has(requestCacheKey(request))111113 plan.Requests = append(plan.Requests, planned)112114 plan.Estimated += asset.EstimateBytes113115 plan.Licenses = append(plan.Licenses, Obligation{Source: asset.ID, License: asset.License, Attribution: asset.Attribution})
@@ -129,7 +131,7 @@ func assetRequest(project Project, asset Asset) Request {
129131 MediaType: asset.MediaType, License: asset.License, Attribution: asset.Attribution,130132 Asset: asset.ID,131133 }132- request.ID = requestID(request)134+ finalizeRequest(&request)133135 return request134136 }135137
@@ -191,7 +193,7 @@ func featureRequest(project Project, source Source) (Request, error) {
191193 Locator: locator, IdentityLocator: identity, MediaType: source.MediaType,192194 License: source.License, Attribution: source.Attribution,193195 }194- request.ID = requestID(request)196+ finalizeRequest(&request)195197 return request, nil196198 }197199
@@ -203,7 +205,7 @@ func rasterRequests(project Project, raster Raster) ([]Request, error) {
203205 License: raster.License, Attribution: raster.Attribution,204206 Raster: raster.ID, Zoom: zoom, X: x, Y: y,205207 }206- request.ID = requestID(request)208+ finalizeRequest(&request)207209 return request208210 }209211 if raster.Adapter == "raster-file" {
@@ -213,7 +215,10 @@ func rasterRequests(project Project, raster Raster) ([]Request, error) {
213215 for _, level := range raster.Levels {214216 for y := level.MinY; y <= level.MaxY; y++ {215217 for x := level.MinX; x <= level.MaxX; x++ {216- identity := strings.NewReplacer("{z}", strconv.FormatInt(level.Zoom, 10), "{x}", strconv.FormatInt(x, 10), "{y}", strconv.FormatInt(y, 10)).Replace(raster.Locator)218+ identity := strings.NewReplacer(219+ "{z}", strconv.FormatInt(level.Zoom, 10), "{x}", strconv.FormatInt(x, 10), "{y}", strconv.FormatInt(y, 10),220+ "{TileMatrix}", strconv.FormatInt(level.Zoom, 10), "{TileCol}", strconv.FormatInt(x, 10), "{TileRow}", strconv.FormatInt(y, 10),221+ ).Replace(raster.Locator)217222 requests = append(requests, makeRequest(project.ResolveLocator(identity), identity, level.Zoom, x, y))218223 }219224 }
@@ -223,14 +228,60 @@ func rasterRequests(project Project, raster Raster) ([]Request, error) {
223228 224229 func requestID(request Request) string {225230 canonical := struct {226- Kind, Source, Adapter, Locator, Raster, Asset string227- Zoom, X, Y int64228- }{string(request.Kind), request.Source, request.Adapter, request.IdentityLocator, request.Raster, request.Asset, request.Zoom, request.X, request.Y}231+ Source, Raster, Asset, Acquisition string232+ }{request.Source, request.Raster, request.Asset, request.AcquisitionID}233+ if canonical.Acquisition == "" {234+ canonical.Acquisition = acquisitionID(request)235+ }236+ data, _ := json.Marshal(canonical)237+ digest := sha256.Sum256(data)238+ return hex.EncodeToString(digest[:])239+}240+241+func acquisitionID(request Request) string {242+ canonical := struct {243+ Kind, Adapter, Version, Locator, MediaType string244+ Zoom, X, Y int64245+ }{string(request.Kind), request.Adapter, request.AdapterVersion, request.IdentityLocator, request.MediaType, request.Zoom, request.X, request.Y}229246 data, _ := json.Marshal(canonical)230247 digest := sha256.Sum256(data)231248 return hex.EncodeToString(digest[:])232249 }233250 251+func finalizeRequest(request *Request) {252+ request.AdapterVersion = adapterVersion(request.Adapter)253+ request.AcquisitionID = acquisitionID(*request)254+ request.ID = requestID(*request)255+}256+257+func adapterVersion(adapter string) string {258+ switch adapter {259+ case "geojson":260+ return "geojson/v1"261+ case "arcgis-feature-service":262+ return "arcgis-feature-service/v1"263+ case "ogc-api-features":264+ return "ogc-api-features/v1"265+ case "raster-file":266+ return "raster-file/v1"267+ case "xyz":268+ return "xyz/v1"269+ case "wmts":270+ return "wmts-rest/v1"271+ case "asset-file":272+ return "asset-file/v1"273+ default:274+ return adapter + "/unknown"275+ }276+}277+278+func requestCacheKey(request Request) string {279+ if request.AcquisitionID != "" {280+ return request.AcquisitionID281+ }282+ return request.ID283+}284+234285 func projectDigest(project Project) (string, error) {235286 data, err := json.Marshal(project)236287 if err != nil {
A internal/authoring/plan_test.go +83 -0
@@ -0,0 +1,83 @@
1+package authoring2+3+import (4+ "path/filepath"5+ "strings"6+ "testing"7+)8+9+func TestAcquisitionIdentityIsSharedAcrossBindingsAndVersionsInputs(t *testing.T) {10+ first := Request{11+ Kind: RequestFeatures, Source: "sample-places", Adapter: "geojson",12+ IdentityLocator: "https://example.invalid/sample-region.geojson", MediaType: "application/geo+json",13+ }14+ second := first15+ second.Source = "alternate-places"16+ finalizeRequest(&first)17+ finalizeRequest(&second)18+ if first.AcquisitionID != second.AcquisitionID {19+ t.Fatalf("same acquisition has different identities: %s != %s", first.AcquisitionID, second.AcquisitionID)20+ }21+ if first.ID == second.ID {22+ t.Fatal("distinct source bindings share a request identity")23+ }24+ changed := first25+ changed.MediaType = "application/json"26+ finalizeRequest(&changed)27+ if changed.AcquisitionID == first.AcquisitionID {28+ t.Fatal("Accept/media type did not change acquisition identity")29+ }30+}31+32+func TestMappingAndPresentationChangesReuseAcquisitionEvidence(t *testing.T) {33+ project, err := LoadProject(filepath.Join("..", "..", "examples", "sample-region"+ProjectExtension))34+ if err != nil {35+ t.Fatal(err)36+ }37+ first, err := PlanProject(project, nil, t.TempDir())38+ if err != nil {39+ t.Fatal(err)40+ }41+ project.Sources[0].Mapping.FeatureTitle = "properties.alternate_name"42+ project.Presentation.Styles[0].Fill = "#123456"43+ second, err := PlanProject(project, nil, first.Output)44+ if err != nil {45+ t.Fatal(err)46+ }47+ if first.ProjectDigest == second.ProjectDigest {48+ t.Fatal("mapping/presentation policy did not change the project digest")49+ }50+ if len(first.Requests) != len(second.Requests) {51+ t.Fatalf("request count changed: %d != %d", len(first.Requests), len(second.Requests))52+ }53+ for index := range first.Requests {54+ if first.Requests[index].ID != second.Requests[index].ID || first.Requests[index].AcquisitionID != second.Requests[index].AcquisitionID {55+ t.Fatalf("policy change invalidated evidence request %d", index)56+ }57+ }58+}59+60+func TestWMTSPlansAnExplicitPortableWindow(t *testing.T) {61+ project := Project{baseDir: t.TempDir()}62+ raster := Raster{63+ ID: "background", Name: "Sample Background", Adapter: "wmts",64+ Locator: "https://example.invalid/tiles/{TileMatrix}/{TileRow}/{TileCol}.png",65+ MediaType: "image/png", TileSize: 256,66+ Levels: []RasterLevel{{Zoom: 0, MinX: 0, MinY: 0, MaxX: 0, MaxY: 0}, {Zoom: 1, MinX: 0, MinY: 0, MaxX: 1, MaxY: 1}},67+ }68+ if err := validateRaster(raster); err != nil {69+ t.Fatal(err)70+ }71+ requests, err := rasterRequests(project, raster)72+ if err != nil {73+ t.Fatal(err)74+ }75+ if len(requests) != 5 || requests[0].AdapterVersion != "wmts-rest/v1" {76+ t.Fatalf("WMTS plan = %d requests, version %q", len(requests), requests[0].AdapterVersion)77+ }78+ for _, request := range requests {79+ if strings.Contains(request.IdentityLocator, "{") || request.AcquisitionID == "" {80+ t.Fatalf("unresolved WMTS request: %+v", request)81+ }82+ }83+}
M internal/workbench/assets/workbench.css +146 -7
@@ -237,13 +237,6 @@ textarea {
237237 .manifest-editor button { margin-top: 10px; }238238 .eyebrow { color: var(--accent); text-transform: uppercase; letter-spacing: .12em; font-size: 11px; margin: 0 0 3px; }239239 .notice { color: var(--accent); border-left: 2px solid var(--accent); padding: 8px 12px; }240-.project-flow { display: flex; align-items: stretch; gap: 8px; margin: 0 0 16px; overflow-x: auto; }241-.project-flow div { min-width: 130px; flex: 1; border: 1px solid var(--line); background: var(--panel); padding: 12px 14px; }242-.project-flow div.accent { border-color: var(--accent); }243-.project-flow b, .project-flow span { display: block; }244-.project-flow b { color: var(--data); font-size: 18px; }245-.project-flow span { color: var(--muted); font-size: 11px; }246-.project-flow i { align-self: center; color: var(--muted); font-style: normal; }247240 .project-columns { grid-template-columns: minmax(360px, 1fr) minmax(420px, 1.2fr); }248241 .build-control form label { display: flex; gap: 5px; align-items: center; }249242 .build-control input[type="checkbox"] { min-width: 0; }
@@ -254,8 +247,154 @@ textarea {
254247 .artifact-handoff form { margin: 0; }255248 .file-icon { display: inline-grid; place-items: center; width: 30px; height: 36px; margin-right: 10px; color: var(--accent-ink); background: var(--accent); font-weight: 700; float: left; }256249 250+/* project contract graph ------------------------------------------------ */251+252+.contract-graph { padding: 0; overflow: hidden; }253+.graph-head {254+ display: flex;255+ align-items: end;256+ justify-content: space-between;257+ gap: 24px;258+ padding: 16px 20px;259+ border-bottom: 1px solid var(--line);260+}261+.graph-head h2 { margin: 0; }262+.graph-totals { margin: 0; color: var(--muted); font-size: 12px; text-align: right; }263+.graph-totals b { color: var(--data); }264+.contract-groups { padding: 4px 20px; }265+.contract-group {266+ display: grid;267+ grid-template-columns: minmax(0, 1fr) 28px minmax(220px, 0.32fr);268+ align-items: center;269+ gap: 8px;270+ padding: 14px 0;271+ border-bottom: 1px solid var(--line);272+}273+.source-lanes { display: grid; gap: 8px; min-width: 0; }274+.source-lane {275+ display: grid;276+ grid-template-columns: minmax(135px, 1fr) 20px minmax(120px, 0.72fr) 20px minmax(175px, 1.1fr);277+ align-items: stretch;278+ gap: 6px;279+ min-width: 0;280+}281+.graph-node {282+ position: relative;283+ min-width: 0;284+ padding: 10px 12px;285+ border: 1px solid var(--line-strong);286+ border-radius: 4px;287+ background: var(--panel-raised);288+}289+.graph-node b, .graph-node .node-detail, .graph-node .node-counts { display: block; }290+.graph-node b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }291+.node-kind {292+ display: block;293+ margin-bottom: 3px;294+ color: var(--muted);295+ font-size: 10px;296+ letter-spacing: 0.1em;297+ text-transform: uppercase;298+}299+.node-detail, .node-counts {300+ overflow: hidden;301+ color: var(--muted);302+ font-size: 11px;303+ text-overflow: ellipsis;304+ white-space: nowrap;305+}306+.node-counts { margin-top: 5px; color: var(--data); }307+.source-node { border-left: 2px solid var(--warm); }308+.mapping-node { border-left: 2px solid var(--data); }309+.contract-node { border-color: var(--accent); background: var(--accent-wash); }310+.contract-node h3 { margin: 0; font-size: 15px; }311+.contract-node > code { color: var(--muted); font-size: 11px; }312+.contract-node > p { margin: 8px 0; }313+.graph-arrow {314+ align-self: center;315+ color: var(--muted);316+ font-size: 18px;317+ font-style: normal;318+ text-align: center;319+}320+.merge-arrow { color: var(--accent); }321+.status-chip {322+ display: inline-block;323+ width: max-content;324+ margin: 6px 4px 0 0;325+ padding: 1px 6px;326+ border: 1px solid var(--line-strong);327+ border-radius: 999px;328+ color: var(--muted);329+ font-size: 10px;330+ font-style: normal;331+ font-weight: 500;332+ letter-spacing: 0.04em;333+ line-height: 1.5;334+ text-transform: uppercase;335+}336+.status-chip.ready, .status-chip.semantic { border-color: var(--accent); color: var(--data); }337+.status-chip.fetch { border-color: var(--warm); color: var(--warm-strong); }338+.status-chip.geometry { color: var(--text); }339+.contract-detail { margin-top: 7px; border-top: 1px solid var(--line); }340+.contract-detail summary, .plan-detail summary, .build-log summary, .manifest-editor > summary {341+ padding: 7px 0;342+ color: var(--muted);343+ cursor: pointer;344+ font-size: 12px;345+}346+.contract-detail summary:hover, .plan-detail summary:hover, .build-log summary:hover, .manifest-editor > summary:hover { color: var(--accent); }347+.contract-list { margin: 0; }348+.contract-list div { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; }349+.contract-list dt, .contract-list dd { margin: 0; font-size: 11px; }350+.artifact-flow {351+ display: grid;352+ grid-template-columns: minmax(0, 1fr) 28px minmax(190px, 0.22fr);353+ align-items: center;354+ gap: 8px;355+ padding: 14px 20px;356+ background: var(--bg);357+}358+.side-inputs { display: flex; flex-wrap: wrap; gap: 6px; }359+.side-input { padding: 5px 8px; border: 1px solid var(--line); border-radius: 3px; color: var(--muted); font-size: 11px; }360+.side-input .status-chip { margin: 0 0 0 4px; }361+.artifact-node { border-color: var(--accent); }362+.artifact-node > b { color: var(--data); font-size: 18px; }363+.plan-detail { margin-top: 8px; border-top: 1px solid var(--line); }364+.manifest-editor > summary { list-style-position: outside; padding: 0; }365+.manifest-editor > summary b { margin-right: 8px; color: var(--text-strong); font-size: 15px; }366+.stage-rail {367+ display: grid;368+ grid-template-columns: repeat(5, 1fr);369+ gap: 0;370+ margin: 14px 0 10px;371+ padding: 0;372+ list-style: none;373+}374+.stage-rail li { position: relative; padding-top: 14px; color: var(--muted); font-size: 10px; text-align: center; text-transform: uppercase; letter-spacing: .06em; }375+.stage-rail li::before { content: ""; position: absolute; top: 4px; left: 0; right: 0; height: 2px; background: var(--line-strong); }376+.stage-rail li:first-child::before { left: 50%; }377+.stage-rail li:last-child::before { right: 50%; }378+.stage-rail li > span { position: absolute; z-index: 1; top: 0; left: calc(50% - 5px); width: 10px; height: 10px; border: 2px solid var(--line-strong); border-radius: 50%; background: var(--panel); }379+.stage-rail li.done { color: var(--data); }380+.stage-rail li.done::before, .stage-rail li.done > span { border-color: var(--accent); background: var(--accent); }381+.stage-rail li.active { color: var(--text-strong); }382+.stage-rail li.active > span { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-wash-strong); }383+.stage-rail li.failed { color: #d98a72; }384+.stage-rail li.failed > span { border-color: #d98a72; background: #d98a72; }385+.build-log { margin: 8px 0; }386+.build-log .console { margin-top: 4px; }387+257388 @media (max-width: 850px) {258389 .project-columns { grid-template-columns: 1fr; }390+ .graph-head { align-items: start; flex-direction: column; gap: 6px; }391+ .graph-totals { text-align: left; }392+ .contract-group { grid-template-columns: 1fr; }393+ .source-lane { grid-template-columns: 1fr; }394+ .source-lane .graph-arrow, .merge-arrow { transform: rotate(90deg); }395+ .contract-group > .graph-arrow { justify-self: center; }396+ .artifact-flow { grid-template-columns: 1fr; }397+ .artifact-flow > .graph-arrow { transform: rotate(90deg); }259398 }260399 261400 /* the console: one row per line the operation said ----------------------- */
M internal/workbench/operations_test.go +91 -3
@@ -10,6 +10,9 @@ import (
1010 "strings"1111 "testing"1212 "time"13+14+ "github.com/FelineStateMachine/atlas/internal/authoring"15+ "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"1316 )1417 1518 func writeSampleProject(t *testing.T, dir string) string {
@@ -32,6 +35,11 @@ target:
3235 unit: metre3336 definition: local sample grid3437 extent: [0, 0, 100, 100]38+feature-sets:39+ - id: markers40+ title: Markers41+ semantic-type: place42+ geometry: point3543 sources:3644 - id: sample-features3745 adapter: geojson
@@ -41,12 +49,9 @@ sources:
4149 attribution: Sample Region contributors4250 mapping:4351 feature-set: markers44- title: Markers45- semantic-type: place4652 identity: id4753 feature-title: properties.name4854 geometry:49- family: point5055 source-space: local5156 transform:5257 kind: identity
@@ -128,6 +133,89 @@ func TestProjectPageMakesTheNativeBuildGraphVisible(t *testing.T) {
128133 }129134 }130135 136+func TestProjectPageShowsTheSourceToContractLaneWithoutOpeningEditors(t *testing.T) {137+ targets := projectTargets(t)138+ held, err := New(Options{Targets: targets, Runtime: []byte("/* runtime */")})139+ if err != nil {140+ t.Fatal(err)141+ }142+ server := site(t, held)143+ response, body := get(t, server, "/project")144+ if response.StatusCode != http.StatusOK {145+ t.Fatalf("project answered %d: %s", response.StatusCode, body)146+ }147+ wants(t, "contract graph", body,148+ "Sources become semantic FeatureSets", "Source", "sample-region.geojson", "Adapter", "geojson",149+ "Mapping", "point · identity", "id → properties.name", "FeatureSet contract", "Markers",150+ "0 typed properties", "0 relationships", "Artifact", "native vNext · planned",151+ "Build stages", "Edit the single configuration file")152+ if strings.Contains(body, `<details class="card manifest-editor" open`) {153+ t.Fatal("valid manifest editor was open by default")154+ }155+}156+157+func TestGraphProjectGroupsSourcesIntoOneTypedContract(t *testing.T) {158+ project := authoring.Project{159+ Presentation: authoring.Presentation{Styles: []authoring.Style{{ID: "style"}}, Layers: []authoring.Layer{{ID: "layer"}}},160+ FeatureSets: []authoring.FeatureSetContract{{161+ ID: "places", Title: "Places", SemanticType: "place", Geometry: "point",162+ Properties: []authoring.PropertyContract{163+ {ID: "name", Name: "Name", Type: "string"},164+ {ID: "rank", Name: "Rank", Type: "int64", Optional: true},165+ },166+ Relationships: []authoring.RelationshipContract{{ID: "area", Predicate: "within", FeatureSet: "areas"}},167+ }},168+ Sources: []authoring.Source{169+ {ID: "primary", Adapter: "geojson", Locator: "primary.geojson", Mapping: authoring.Mapping{170+ FeatureSet: "places", Identity: "id", FeatureTitle: "properties.name",171+ Geometry: authoring.GeometryMap{SourceSpace: "local", Transform: authoring.CoordinateTransform{Kind: "identity"}},172+ Properties: []authoring.PropertyMap{{Field: "name", Source: "properties.name"}},173+ Relations: []authoring.RelationMap{{Relationship: "area", Target: "properties.area"}},174+ }},175+ {ID: "secondary", Adapter: "ogc-api-features", Locator: "https://example.invalid/features", Mapping: authoring.Mapping{176+ FeatureSet: "places", Identity: "id", FeatureTitle: "properties.name",177+ Geometry: authoring.GeometryMap{SourceSpace: "local", Transform: authoring.CoordinateTransform{Kind: "identity"}},178+ Properties: []authoring.PropertyMap{{Field: "rank", Source: "properties.rank"}},179+ }},180+ },181+ }182+ plan := authoring.Plan{Requests: []authoring.PlannedRequest{183+ {Request: authoring.Request{Kind: authoring.RequestFeatures, Source: "primary"}, Cached: true},184+ {Request: authoring.Request{Kind: authoring.RequestFeatures, Source: "secondary"}},185+ }}186+ graph := graphProject(project, plan)187+ if len(graph.FeatureSets) != 1 || len(graph.FeatureSets[0].Sources) != 2 {188+ t.Fatalf("feature set graph = %+v", graph.FeatureSets)189+ }190+ set := graph.FeatureSets[0]191+ if len(set.Properties) != 2 || set.Properties[0].ID != "name" || set.Properties[1].ID != "rank" || !set.Properties[1].Optional {192+ t.Fatalf("contract properties = %+v", set.Properties)193+ }194+ if len(set.Relations) != 1 || set.Relations[0].Predicate != "within" || set.Relations[0].Target != "areas" {195+ t.Fatalf("contract relations = %+v", set.Relations)196+ }197+ if !set.Sources[0].Cached || set.Sources[1].Cached {198+ t.Fatalf("source cache states = %+v", set.Sources)199+ }200+}201+202+func TestProjectStagesDeriveProgressFromBuildEvents(t *testing.T) {203+ run := supervisedRun{Name: "build", Running: true, Rows: []oprunner.Row{204+ {Attrs: []oprunner.Attr{{Key: "stage", Value: "plan"}}},205+ {Attrs: []oprunner.Attr{{Key: "stage", Value: "capture"}}},206+ }}207+ stages := projectStages(run)208+ if stages[0].State != "done" || stages[1].State != "active" || stages[2].State != "pending" {209+ t.Fatalf("running stages = %+v", stages)210+ }211+ run.Running, run.Failed = false, true212+ run.Rows = append(run.Rows, oprunner.Row{Failed: true, Attrs: []oprunner.Attr{{Key: "stage", Value: "observe"}}})213+ stages = projectStages(run)214+ if stages[0].State != "done" || stages[1].State != "done" || stages[2].State != "failed" {215+ t.Fatalf("failed stages = %+v", stages)216+ }217+}218+131219 func TestManifestSaveValidatesBeforeAtomicReplacement(t *testing.T) {132220 targets := projectTargets(t)133221 held, err := New(Options{Targets: targets})
M internal/workbench/project.go +186 -5
@@ -6,6 +6,7 @@ import (
66 "net/http"77 "os"88 "path/filepath"9+ "sort"910 "strings"1011 1112 "github.com/FelineStateMachine/atlas/format/vnext"
@@ -30,11 +31,76 @@ type projectPage struct {
3031 CachedCount int3132 RequestCount int3233 ArtifactName string34+ Graph projectGraph35+ Stages []projectBuildStage36+}37+38+// projectGraph is the manifest and resolved plan shaped for the page. It is a39+// view only: adapters still acquire bytes, mappings still assign meaning, and40+// the compiler remains the sole authority for the artifact.41+type projectGraph struct {42+ FeatureSets []projectFeatureSet43+ Rasters []projectSideInput44+ Assets []projectSideInput45+ Styles int46+ Layers int47+}48+49+type projectFeatureSet struct {50+ ID string51+ Title string52+ SemanticType string53+ Geometry string54+ Sources []projectSourceLane55+ Properties []projectProperty56+ Relations []projectRelation57+}58+59+type projectSourceLane struct {60+ ID string61+ Locator string62+ Adapter string63+ Cache string64+ Cached bool65+ Identity string66+ FeatureTitle string67+ Geometry string68+ SourceSpace string69+ Transform string70+ PropertyCount int71+ RelationCount int72+}73+74+type projectProperty struct {75+ ID string76+ Name string77+ Type string78+ Optional bool79+}80+81+type projectRelation struct {82+ Predicate string83+ Target string84+ Optional bool85+}86+87+type projectSideInput struct {88+ ID string89+ Adapter string90+ Cache string91+ Cached bool92+}93+94+type projectBuildStage struct {95+ ID string96+ Label string97+ State string3398 }3499 35100 func (w *Workbench) projectPage(notice string) projectPage {36101 page := projectPage{Targets: w.targets, Notice: notice, Run: w.supervisor.Snapshot()}37102 page.ArtifactName = filepath.Base(page.Run.Artifact)103+ page.Stages = projectStages(page.Run)38104 data, err := os.ReadFile(w.targets.Project)39105 if err != nil {40106 page.Error = err.Error()
@@ -57,17 +123,132 @@ func (w *Workbench) projectPage(notice string) projectPage {
57123 return page58124 }59125 page.Plan = plan126+ page.Graph = graphProject(project, plan)60127 page.SourceCount = len(project.Sources)61128 page.RasterCount = len(project.Rasters)62129 page.LayerCount = len(project.Presentation.Layers)63130 page.CachedCount = plan.Cached64131 page.RequestCount = len(plan.Requests)65- sets := make(map[string]bool)132+ page.FeatureSets = len(project.FeatureSets)133+ return page134+}135+136+func graphProject(project authoring.Project, plan authoring.Plan) projectGraph {137+ graph := projectGraph{Styles: len(project.Presentation.Styles), Layers: len(project.Presentation.Layers)}138+ requestCache := make(map[string]bool, len(plan.Requests))139+ for _, request := range plan.Requests {140+ requestCache[string(request.Kind)+"\x00"+request.Source] = request.Cached141+ }142+ setIndex := make(map[string]int)143+ for _, contract := range project.FeatureSets {144+ setIndex[contract.ID] = len(graph.FeatureSets)145+ set := projectFeatureSet{146+ ID: contract.ID, Title: contract.Title, SemanticType: contract.SemanticType, Geometry: contract.Geometry,147+ }148+ for _, property := range contract.Properties {149+ set.Properties = append(set.Properties, projectProperty{150+ ID: property.ID, Name: property.Name, Type: property.Type, Optional: property.Optional,151+ })152+ }153+ for _, relation := range contract.Relationships {154+ set.Relations = append(set.Relations, projectRelation{155+ Predicate: relation.Predicate, Target: relation.FeatureSet, Optional: relation.Optional,156+ })157+ }158+ sort.Slice(set.Properties, func(i, j int) bool { return set.Properties[i].ID < set.Properties[j].ID })159+ sort.Slice(set.Relations, func(i, j int) bool {160+ if set.Relations[i].Predicate != set.Relations[j].Predicate {161+ return set.Relations[i].Predicate < set.Relations[j].Predicate162+ }163+ return set.Relations[i].Target < set.Relations[j].Target164+ })165+ graph.FeatureSets = append(graph.FeatureSets, set)166+ }66167 for _, source := range project.Sources {67- sets[source.Mapping.FeatureSet] = true168+ setID := source.Mapping.FeatureSet169+ at, held := setIndex[setID]170+ if !held {171+ continue172+ }173+ set := &graph.FeatureSets[at]174+ cached := requestCache[string(authoring.RequestFeatures)+"\x00"+source.ID]175+ cache := "fetch"176+ if cached {177+ cache = "ready"178+ }179+ set.Sources = append(set.Sources, projectSourceLane{180+ ID: source.ID, Locator: source.Locator, Adapter: source.Adapter, Cache: cache, Cached: cached,181+ Identity: source.Mapping.Identity, FeatureTitle: source.Mapping.FeatureTitle,182+ Geometry: set.Geometry, SourceSpace: source.Mapping.Geometry.SourceSpace,183+ Transform: source.Mapping.Geometry.Transform.Kind,184+ PropertyCount: len(source.Mapping.Properties), RelationCount: len(source.Mapping.Relations),185+ })186+ }187+ for _, raster := range project.Rasters {188+ ready := true189+ found := false190+ for _, request := range plan.Requests {191+ if request.Kind != authoring.RequestRaster || request.Source != raster.ID {192+ continue193+ }194+ found = true195+ ready = ready && request.Cached196+ }197+ graph.Rasters = append(graph.Rasters, projectSideInput{198+ ID: raster.ID, Adapter: raster.Adapter, Cache: cacheWord(found && ready), Cached: found && ready,199+ })68200 }69- page.FeatureSets = len(sets)70- return page201+ for _, asset := range project.Assets {202+ ready := requestCache[string(authoring.RequestAsset)+"\x00"+asset.ID]203+ graph.Assets = append(graph.Assets, projectSideInput{204+ ID: asset.ID, Adapter: "asset-file", Cache: cacheWord(ready), Cached: ready,205+ })206+ }207+ return graph208+}209+210+func cacheWord(cached bool) string {211+ if cached {212+ return "ready"213+ }214+ return "fetch"215+}216+217+func projectStages(run supervisedRun) []projectBuildStage {218+ stages := []projectBuildStage{219+ {ID: "plan", Label: "Plan", State: "pending"},220+ {ID: "capture", Label: "Capture", State: "pending"},221+ {ID: "observe", Label: "Observe", State: "pending"},222+ {ID: "assemble", Label: "Assemble", State: "pending"},223+ {ID: "publish", Label: "Publish", State: "pending"},224+ }225+ if run.Name == "" {226+ return stages227+ }228+ current := 0229+ for _, row := range run.Rows {230+ for _, attr := range row.Attrs {231+ if attr.Key != "stage" {232+ continue233+ }234+ for index := range stages {235+ if stages[index].ID == attr.Value && index > current {236+ current = index237+ }238+ }239+ }240+ }241+ for index := range stages {242+ if index < current || !run.Running && !run.Failed && index <= current {243+ stages[index].State = "done"244+ } else if index == current && run.Running {245+ stages[index].State = "active"246+ }247+ }248+ if run.Failed {249+ stages[current].State = "failed"250+ }251+ return stages71252 }72253 73254 func (w *Workbench) handleProject(rw http.ResponseWriter, r *http.Request) {
@@ -158,7 +339,7 @@ func (w *Workbench) handleProjectBuild(rw http.ResponseWriter, r *http.Request)
158339 159340 func (w *Workbench) handleProjectRun(rw http.ResponseWriter, _ *http.Request) {160341 run := w.supervisor.Snapshot()161- page := projectPage{Run: run, ArtifactName: filepath.Base(run.Artifact)}342+ page := projectPage{Run: run, ArtifactName: filepath.Base(run.Artifact), Stages: projectStages(run)}162343 var body bytes.Buffer163344 if err := w.pages["project"].ExecuteTemplate(&body, "project-run", page); err != nil {164345 http.Error(rw, err.Error(), http.StatusInternalServerError)
M internal/workbench/templates/project.tmpl +98 -24
@@ -9,16 +9,80 @@
99 </section>1010 1111 {{if .Project.ID}}12-<section class="project-flow" aria-label="Build flow">13- <div><b>{{.SourceCount}}</b><span>feature sources</span></div>14- <i>→</i>15- <div><b>{{.FeatureSets}}</b><span>semantic sets</span></div>16- <i>→</i>17- <div><b>{{.RasterCount}}</b><span>raster pyramids</span></div>18- <i>→</i>19- <div><b>{{.LayerCount}}</b><span>presentation layers</span></div>20- <i>→</i>21- <div class="accent"><b>.atlas</b><span>native vNext</span></div>12+<section class="card contract-graph" aria-labelledby="contract-graph-title">13+ <header class="graph-head">14+ <div>15+ <p class="eyebrow">Resolved authoring contract</p>16+ <h2 id="contract-graph-title">Sources become semantic FeatureSets</h2>17+ </div>18+ <p class="graph-totals"><b>{{.SourceCount}}</b> feature sources · <b>{{.FeatureSets}}</b> semantic sets · <b>{{.RasterCount}}</b> raster pyramids · <b>{{.LayerCount}}</b> presentation layers</p>19+ </header>20+21+ <div class="contract-groups">22+ {{range .Graph.FeatureSets}}23+ <section class="contract-group" aria-labelledby="feature-set-{{.ID}}">24+ <div class="source-lanes">25+ {{range .Sources}}26+ <div class="source-lane">27+ <article class="graph-node source-node">28+ <span class="node-kind">Source</span>29+ <b>{{.ID}}</b>30+ <span class="node-detail" title="{{.Locator}}">{{.Locator}}</span>31+ <span class="status-chip {{if .Cached}}ready{{else}}fetch{{end}}">{{.Cache}}</span>32+ </article>33+ <i class="graph-arrow" aria-hidden="true">→</i>34+ <article class="graph-node adapter-node">35+ <span class="node-kind">Adapter</span>36+ <b>{{.Adapter}}</b>37+ <span class="node-detail">immutable evidence</span>38+ </article>39+ <i class="graph-arrow" aria-hidden="true">→</i>40+ <article class="graph-node mapping-node">41+ <span class="node-kind">Mapping</span>42+ <b>{{.Geometry}} · {{.Transform}}</b>43+ <span class="node-detail">{{.Identity}} → {{.FeatureTitle}}</span>44+ <span class="node-detail">{{.SourceSpace}} → world</span>45+ <span class="node-counts">{{.PropertyCount}} properties{{if .RelationCount}} · {{.RelationCount}} relationships{{end}}</span>46+ </article>47+ </div>48+ {{end}}49+ </div>50+ <i class="graph-arrow merge-arrow" aria-hidden="true">→</i>51+ <article class="graph-node contract-node">52+ <span class="node-kind">FeatureSet contract</span>53+ <h3 id="feature-set-{{.ID}}">{{.Title}}</h3>54+ <code>{{.ID}}</code>55+ <p><span class="status-chip semantic">{{.SemanticType}}</span><span class="status-chip geometry">{{.Geometry}}</span></p>56+ <details class="contract-detail">57+ <summary>{{len .Properties}} typed properties</summary>58+ {{if .Properties}}<dl class="contract-list">59+ {{range .Properties}}<div><dt>{{.Name}}</dt><dd><code>{{.Type}}</code>{{if .Optional}} <span class="dim">optional</span>{{end}}</dd></div>{{end}}60+ </dl>{{else}}<p class="dim">No typed properties.</p>{{end}}61+ </details>62+ <details class="contract-detail">63+ <summary>{{len .Relations}} relationships</summary>64+ {{if .Relations}}<dl class="contract-list">65+ {{range .Relations}}<div><dt>{{.Predicate}}</dt><dd>→ <code>{{.Target}}</code>{{if .Optional}} <span class="dim">optional</span>{{end}}</dd></div>{{end}}66+ </dl>{{else}}<p class="dim">No relationships.</p>{{end}}67+ </details>68+ </article>69+ </section>70+ {{end}}71+ </div>72+73+ <div class="artifact-flow">74+ <div class="side-inputs" aria-label="Other artifact inputs">75+ {{range .Graph.Rasters}}<span class="side-input"><b>{{.ID}}</b> · {{.Adapter}} <i class="status-chip {{if .Cached}}ready{{else}}fetch{{end}}">{{.Cache}}</i></span>{{end}}76+ {{range .Graph.Assets}}<span class="side-input"><b>{{.ID}}</b> · asset <i class="status-chip {{if .Cached}}ready{{else}}fetch{{end}}">{{.Cache}}</i></span>{{end}}77+ <span class="side-input"><b>{{.Graph.Layers}}</b> layers · <b>{{.Graph.Styles}}</b> styles</span>78+ </div>79+ <i class="graph-arrow" aria-hidden="true">→</i>80+ <div class="graph-node artifact-node">81+ <span class="node-kind">Artifact</span>82+ <b>.atlas</b>83+ <span class="node-detail">native vNext · planned</span>84+ </div>85+ </div>2286 </section>2387 2488 <div class="columns project-columns">
@@ -31,13 +95,17 @@
3195 <div><dt>estimated input</dt><dd>{{.Plan.Estimated}} bytes</dd></div>3296 <div><dt>output library</dt><dd>{{.Plan.Output}}</dd></div>3397 </dl>34- <h3>Evidence requests</h3>35- <table>36- <thead><tr><th>source</th><th>adapter</th><th>kind</th><th>cache</th></tr></thead>37- <tbody>{{range .Plan.Requests}}<tr><td>{{.Source}}</td><td>{{.Adapter}}</td><td>{{.Kind}}</td><td>{{if .Cached}}ready{{else}}fetch{{end}}</td></tr>{{end}}</tbody>38- </table>39- <h3>Obligations carried into provenance</h3>40- {{range .Plan.Licenses}}<p><b>{{.Source}}</b>{{if .License}} · {{.License}}{{end}}{{if .Attribution}}<br><span class="dim">{{.Attribution}}</span>{{end}}</p>{{end}}98+ <details class="plan-detail">99+ <summary>{{len .Plan.Requests}} evidence requests</summary>100+ <table>101+ <thead><tr><th>source</th><th>adapter</th><th>kind</th><th>cache</th></tr></thead>102+ <tbody>{{range .Plan.Requests}}<tr><td>{{.Source}}</td><td>{{.Adapter}}</td><td>{{.Kind}}</td><td>{{if .Cached}}ready{{else}}fetch{{end}}</td></tr>{{end}}</tbody>103+ </table>104+ </details>105+ <details class="plan-detail">106+ <summary>{{len .Plan.Licenses}} provenance obligations</summary>107+ {{range .Plan.Licenses}}<p><b>{{.Source}}</b>{{if .License}} · {{.License}}{{end}}{{if .Attribution}}<br><span class="dim">{{.Attribution}}</span>{{end}}</p>{{end}}108+ </details>41109 </section>42110 43111 <section class="card build-control">
@@ -52,25 +120,31 @@
52120 </div>53121 {{end}}54122 55-<section class="card manifest-editor">56- <h2>Portable manifest</h2>57- <p class="dim">Configuration lives here. Captured evidence stays in the shared cache; completed volumes stay in the Atlas library.</p>123+<details class="card manifest-editor"{{if .Error}} open{{end}}>124+ <summary><b>Portable manifest</b> <span class="dim">Edit the single configuration file</span></summary>125+ <p class="dim">Captured evidence stays in the shared cache; completed volumes stay in the Atlas library.</p>58126 <form method="post" action="/project/save">59127 <textarea name="manifest" spellcheck="false" aria-label="Atlas project manifest">{{.Manifest}}</textarea>60128 <button>Validate and save</button>61129 </form>62-</section>130+</details>63131 {{end}}64132 65133 {{define "project-run"}}66134 <div id="project-run"{{if .Run.Running}} hx-get="/project/run" hx-trigger="load, every 1s" hx-swap="outerHTML"{{end}}>135+ <ol class="stage-rail" aria-label="Build stages">136+ {{range .Stages}}<li class="{{.State}}" data-stage="{{.ID}}"><span aria-hidden="true"></span><b>{{.Label}}</b></li>{{end}}137+ </ol>67138 {{if .Run.Name}}68139 <p class="run-state {{if .Run.Failed}}failed{{else if .Run.Running}}running{{else}}done{{end}}">69140 {{if .Run.Running}}Building… safe to leave this page{{else if .Run.Failed}}Build failed{{else}}Build complete{{end}}70141 </p>71- <div id="op-log" class="console" aria-live="polite">72- {{range .Run.Rows}}{{template "op-row" .}}{{end}}73- </div>142+ <details class="build-log"{{if .Run.Failed}} open{{end}}>143+ <summary>Raw diagnostics <span class="dim">{{len .Run.Rows}} rows</span></summary>144+ <div id="op-log" class="console" aria-live="polite">145+ {{range .Run.Rows}}{{template "op-row" .}}{{end}}146+ </div>147+ </details>74148 {{end}}75149 {{if .Run.Artifact}}76150 <div class="artifact-handoff" draggable="true" data-artifact-handoff