28 Commits

Author SHA1 Message Date
191015e289 Prepare release notes for ConfigKit 1.1.0 2026-11-03 02:25:47 +00:00
7e53863adb Delegate release file checks to ReleaseKit 2026-11-03 01:32:05 +00:00
f7044cf695 Explain source-aware path normalization inside lists 2026-11-03 01:27:33 +00:00
c2b3cb32da Document field-level resolution and matched sequence history 2026-11-03 01:01:25 +00:00
43ac34d10e Adopt ReleaseKit 0.8 and document publication recovery 2026-11-03 00:55:19 +00:00
795cc2f379 Reuse cached pointers when ordering resolution changes 2026-11-02 17:40:12 +00:00
e878ce51ff Index collection prefixes for path correspondence 2026-11-02 17:36:55 +00:00
ebf98ffc62 Propagate matched sensitivity with one work queue 2026-11-02 17:33:44 +00:00
34c3241dae Report moved history at final record paths 2026-11-02 17:29:36 +00:00
25382a112b Preserve matched history for removed fields 2026-11-02 17:24:45 +00:00
95495ae7fc Record sequence resolution efficiency findings 2026-11-02 16:42:14 +00:00
27be6bab23 Record sequence resolution review findings 2026-11-02 16:36:30 +00:00
b066090db4 Verify sequence resolution through the public API 2026-11-02 15:00:48 +00:00
1d8438b635 Protect paired history snapshots from disclosure 2026-11-02 14:58:52 +00:00
3b10f97424 Carry sensitivity across matched records 2026-11-02 14:56:38 +00:00
c296eba6c1 Explain matched resolution and removed records 2026-11-02 14:54:47 +00:00
0799e1d18f Replay matched record input history at entry positions 2026-11-02 14:52:05 +00:00
151b56e7ec Select history using matched entry positions 2026-11-02 14:49:32 +00:00
4549fbaeef Preserve matched record metadata after resolution 2026-11-02 14:47:52 +00:00
c61597d137 Retain matched resolution evidence in history 2026-11-02 14:46:13 +00:00
d120fe907b Audit registered collection changes by identity 2026-11-02 14:43:48 +00:00
7f004f67ad Audit ordinary list changes at field paths 2026-11-02 14:41:42 +00:00
c0a0c753f9 Match registered collection endpoints for resolution 2026-11-02 14:40:04 +00:00
40b00da175 Record sequence resolution correspondence decision 2026-11-02 14:37:22 +00:00
1cee127300 Plan field-level sequence resolution and history 2026-11-02 14:32:15 +00:00
9adcf14966 Clarify history for removed records and reused positions 2026-11-02 14:20:58 +00:00
6b7a960d11 Define identity-aware resolution history in the sequence roadmap 2026-11-02 13:09:03 +00:00
128bf475c3 Record the proposal for field-level list resolution 2026-11-02 12:42:25 +00:00
37 changed files with 1998 additions and 287 deletions

View File

@@ -0,0 +1,65 @@
# ADR 0021: Match Registered Records During Resolution Inspection
## Status
Accepted.
## Date
2026-11-02
## Context
Application resolution can change fields within sequences. Treating every
sequence change as atomic prevents a field record from covering an individual
edit. Registered collection identities also provide a way to relate a final
record to its entry value when positions change, so inspection can retain the
correct source evidence without confusing it with a prior index occupant.
## Decision
Compare equal-length ordinary lists recursively by position during resolution
auditing. A length change requires coverage of the list or an ancestor. For a
collection with an applicable application identity registration, match entry and
final records only when both endpoints have valid, unique identities. A change
in identity order or membership requires list or ancestor coverage, while
surviving records retain their matched entry evidence. When matching is not
available, audit a changed list as a whole without introducing an early identity
failure. Final registration validation remains at its existing boundary.
Match only the resolution-entry and final snapshots, within the same registered
destination. Identity changes are removals and additions, not inferred renames.
Records and helpers address final positions; entry queries remain fixed to entry
coordinates, and helpers do not follow later moves automatically. A surviving
record takes precedence at a reused position; collection inspection retains
removed records' evidence, and absent historical positions remain inspectable.
New records do not inherit a previous occupant's history or explicitness.
Preserve recorded operations, source coordinates, sensitivity, and safe detached
inspection. Resolution comparison is separate from public effective diff, which
continues to present sequence changes atomically.
This extends resolution inspection under [ADR 0003](0003-preserve-presence-and-resolution-history.md)
while preserving the diff decision in [ADR 0010](0010-effective-configuration-diff.md),
source combination and identity rules in [ADR 0017](0017-collection-sequence-policy.md),
and lifecycle ordering in [ADR 0018](0018-cli-after-structural-resolution.md).
## Alternatives Considered
- **Keep all sequence changes atomic during resolution.** This forces whole-list
records for independent field edits and does not identify a moved record's
earlier evidence.
- **Infer identities for every list.** Similar values or conventional field
names do not establish an application identity and could attach unrelated
history to a final record.
- **Reconstruct every intermediate callback mutation.** This requires a new
mutation history and implies an ordering that endpoint snapshots cannot prove.
## Consequences
Resolution auditing and inspection must share entry-to-final correspondence,
including explicit unmatched records, while preserving historical coordinates
and conservative sensitivity masking. Source assembly and public path types do
not change. The completed implementation plan is preserved in Git history at
`7e53863:docs/roadmap/implementation.md`; the
[configuration contract](../config.md) owns implemented behavior.

View File

@@ -772,6 +772,15 @@ in history. Across tiers, history follows the selected
whole-record upsert. Incoming contributions use their assigned positions, and
replaced payload remains available as historical evidence.
During resolution, eligible application-registered records are matched between
entry and final snapshots by identity at the same collection destination.
Queries at a surviving final position follow that record's entry history, while
collection queries retain physical ordering and removed evidence. For example,
if `[reports, invoices]` becomes `[invoices]`, item 0 follows invoices' earlier
position, the collection retains reports' removal, and the absent old item 1
remains available through historical-position queries. An added identity does
not inherit the old occupant's history. Source locations stay source-local.
A mapping assembled from the selected file and imports has one composite base origin
whose locations follow [scoped assembly order](#explicit-imports), not source-record
acquisition order. A mapping also visited by an overlay has an overlay-layer
@@ -1198,6 +1207,18 @@ records, helper targets, and unrecorded mutations fail the load. Equal-valued
derived assignments may still record application intent even when there is no
structural change.
Resolution compares equal-length ordinary lists recursively by position, so an
edit to `/resources/0/path` can be recorded at that field. A length change needs
a list or ancestor record. For application-registered collections, valid unique
entry and final identities match records at the same destination; changing order
or membership still requires list or ancestor coverage. Invalid or duplicate
endpoint identities make a changed list atomic for auditing, without an early
identity error; final registration validation remains at its normal boundary.
YAML-only declarations do not provide this correspondence. Records and helper
targets use final positions, and helpers never follow a later move automatically.
Fixed entry queries continue to use entry positions. See
[ADR 0021](adr/0021-sequence-resolution-correspondence.md) for the decision.
`Resolution.Error` and `Resolution.Warning` attach exact or nearest available input
locations. Reported errors fail the load; warnings survive on a successful result. A
returned resolver error adds one generic `resolve.hook_failed` error only when the
@@ -1356,7 +1377,7 @@ Incompatible overlaps and invalid registrations produce `options.invalid` before
source acquisition. Unused and root registrations are allowed and have no effect.
Registrations provide identity for strict composition, sequence operations,
inheritance lookup, and final validation. Dynamic selectors apply at concrete
inheritance lookup, resolution correspondence, and final validation. Dynamic selectors apply at concrete
strict-composition, inheritance, and final-result destinations without requiring
the application to enumerate mapping keys. Each strict tier has independent
duplicate-identity state among its peers; layered precedence and inheritance
@@ -2056,10 +2077,10 @@ rebuilt by unmarshaling their canonical text into fresh values, detaching privat
backing storage. Ignored and unexported struct fields are zeroed rather than copied.
Private structural comparison operates on canonical trees. Mapping additions,
removals, and descendant replacements retain semantic paths. A sequence is atomic:
any sequence change reports the sequence path and never invents correspondence
between old and new indices. Cross-kind and null/container changes replace the
affected path.
removals, and descendant replacements retain semantic paths. The shared
comparison used by effective diff treats sequences atomically. Resolution uses
its separate positional and registered-identity comparison described above.
Cross-kind and null/container changes replace the affected path.
These mechanisms support decoding, resolution auditing, metadata,
and rendering. They are not a public generic conversion or cloning API.

View File

@@ -308,6 +308,66 @@ application choice, not a ConfigKit fallback: consumers can instead reject it or
choose another explicit base. The example intentionally performs neither target
existence checks nor confinement checks.
### Normalizing paths inside a list
Suppose `/srv/catalog/resources.yml` supplies this configuration, and the
application interprets file-authored relative paths against their declaring
file's directory:
```yaml
resources:
- name: reports
path: ./reports
- name: invoices
path: ./invoices
```
Apply the same source-aware policy as `LoadNotariusPaths` to each `path` field
while the list remains in its entry order:
1. Build the field's semantic path, such as
`configkit.Keys("resources").Index(0).Key("path")`.
2. Query `Resolution.EntryInfo` at that path and use `Resolution.Source` to
identify its declaring file. Choose the base under the application's policy;
defaults, CLI values, and missing or composite provenance need their own
handling, as in the maintained example above.
3. Resolve the relative value against that base and use `Normalize` to assign
and record the individual field. For a mutation already performed by the
application, use `RecordNormalization` instead.
4. Continue without reordering the list. Later resource access uses the resolved
value rather than trying to recover a base directory from its effective origin.
For these file-authored values, the resulting configuration is:
```yaml
resources:
- name: reports
path: /srv/catalog/reports
- name: invoices
path: /srv/catalog/invoices
```
Each changed `path` has a resolution origin, while its history retains the
earlier relative value and source evidence. Unchanged fields such as `name`
retain their source origins. Field-level recording avoids attributing the whole
list to the path-normalization operation; it does not preserve a file origin on
the normalized fields themselves.
If the application also restructures the list during resolution, a whole-list
record covers the structural change. For a collection registered with `name`
as its identity, history follows each surviving record through a reorder, but
descendants can acquire synthetic resolution origins without source locations.
Historical source evidence and effective origins answer different questions;
identity-aware history does not automatically select a resource base for a
derived value. Resolve source-relative values before moving records, and account
for final positions when recording helper targets. `EntryInfo` continues to use
entry positions even after a move.
See [resolution recording](../config.md#resolution-recording-and-failure-rules)
for helper and coverage behavior, and
[origins and historical paths](../config.md#presence-origins-and-historical-paths)
for the distinction between resolution origins and retained source evidence.
## Intentional migration changes
When upgrading, start with the changes relevant to your dependency and inputs:

View File

@@ -65,6 +65,7 @@ Accepted decisions:
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
- [Scope configuration controls to document roots](adr/0020-document-root-controls.md)
- [Match registered records during resolution inspection](adr/0021-sequence-resolution-correspondence.md)
For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -92,6 +93,19 @@ and current facade ownership belongs to the [component overview](internal/overvi
## Baseline validation
The completed field-level sequence resolution roadmap, implementation plan, and
resolved audit findings are preserved in Git history at `7e53863` under
`docs/roadmap/`. For example:
```sh
git show 7e53863:docs/roadmap/implementation.md
git show 7e53863:docs/roadmap/remediation.md
```
The [configuration contract](config.md#recorded-application-resolution) owns
current resolution behavior, and [ADR 0021](adr/0021-sequence-resolution-correspondence.md)
records the correspondence decision.
The completed filesystem-source and underlay roadmap, original feature request,
implementation plan, and resolved remediation record are preserved in Git history
at `b604aa1` under `docs/roadmap/`. Current behavior belongs to the
@@ -250,15 +264,19 @@ dependency downloads. Acquire dependencies and any selected toolchain beforehand
Format reported Go files with `gofmt -w <files>`.
The default suite includes executable examples, maintained consumer fixtures,
local documentation-link checks, and the dependency import-boundary check.
and the dependency/public boundary checks.
It must stay deterministic and runnable without network access or credentials.
Still inspect new/untracked content and review paths, examples, ownership, and
current-versus-planned claims; automated checks do not replace content review.
Use focused checks while iterating, with validation proportionate to the change
under the testing policy. Documentation-only work normally needs the local-link
check below and whitespace review; behavior-bearing examples and commands also
need execution. Use `go test -race ./...` for ownership or concurrency changes.
under the testing policy. Documentation-only work normally needs review of
affected links and whitespace; behavior-bearing examples and commands also
need execution. Automated Markdown link checking runs against the committed
candidate through ReleaseKit, not during ordinary Go tests or the everyday
checker. See the [release procedure](release.md#4-publish-the-committed-candidate)
for the selected checks and optional diagnostic command. Use
`go test -race ./...` for ownership or concurrency changes.
The [release procedure](release.md#4-publish-the-committed-candidate) performs
its selected validation through one `publish` invocation. The everyday checker
remains a development gate and is not a separate release prerequisite. CI never
@@ -285,6 +303,7 @@ code changes. Use the component overview to find the corresponding implementatio
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Resolution sequence matching and inspection | [Correspondence](../internal/engine/resolution_correspondence_test.go), [auditing and history](../internal/engine/resolution_compare_test.go), [public integration](../facade_resolution_sequences_test.go) | `go test ./internal/engine -run 'Resolution\|History\|Explain\|Sensitivity'` and `go test . -run 'Resolution\|SequenceIdentity\|Diff'` |
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
@@ -293,7 +312,7 @@ code changes. Use the component overview to find the corresponding implementatio
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
| Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` |
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
| Dependency and public API isolation | [Import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
## Fuzz validation

View File

@@ -50,7 +50,7 @@ Implemented component owners:
- Engine mechanism tests own source, composition, inheritance, decoding,
callback, provenance,
source-context, secret, history, rendering, and bootstrap behavior. Root tests
own public API compatibility, facade integration, documentation links, and
own public API compatibility, facade integration, and
dependency/public boundary checks. Consumer examples remain under
`examples/consumers`.
@@ -161,6 +161,9 @@ before application resolution begins. The callback retains its separate fixed
entry view; it does not read a builder that continues to change. After successful
resolution auditing, [`resolveLoadInput`](../../internal/engine/load_result.go)
adds the original resolution records and their net replay groups. At the existing
resolution boundary, [`resolution_correspondence.go`](../../internal/engine/resolution_correspondence.go)
matches eligible registered records and the shared history retains their paired
coordinates for selection and safe replay. At the existing
pre-secret admission boundary, [`prepareInspectionHistory`](../../internal/engine/load_result.go)
freezes that builder and counts the retained document roots before secret lookup
and validation.

View File

@@ -376,6 +376,11 @@ it does not cover every intermediate callback state or later caller mutations.
YAML-only declarations carry no such final guarantee through defaults, CLI
assignments, or application resolution.
For resolution auditing and inspection, eligible registered collections match
entry and final records by identity within each destination. Structural changes
still require container coverage, and this correspondence does not change source
composition or move final validation earlier. Public diff remains sequence-atomic.
Preserve source attribution, presence, history, sensitivity, and resource context
as records survive, move, or are replaced. Identity diagnostics use paths and
available origin information without exposing identity values. The guarantee

View File

@@ -213,8 +213,8 @@ test or CI run required before it.
The selected profile runs the complete deterministic, offline default suite
once with the race detector and test-time vet disabled. The suite already
includes examples, consumer fixtures, documentation links, and package
boundaries. One full vet pass adds static checks beyond the test-time subset.
includes examples, consumer fixtures, and package boundaries. One full vet
pass adds static checks beyond the test-time subset.
CGO-disabled Linux amd64 and arm64 package compilation covers supported
configurations distinct from native race compilation. macOS and newer-toolchain
checks are optional.
@@ -231,6 +231,17 @@ delivery token path out of the committed candidate. The profile owns the path;
the check does not inspect file contents or Git history. Hosted credential
preflight belongs to delivery and adds no source-publication gate.
ConfigKit delegates committed Markdown link validation to ReleaseKit rather
than maintaining a documentation-link test in the Go suite. Links outside the
checkout remain out of scope. This moves automated link feedback to candidate
validation; ordinary development review still covers edited links.
ConfigKit also selects ReleaseKit's local-replacement check for committed module
declarations. Filesystem replacements can make local validation succeed with
dependencies unavailable to consumers of the published module. Versioned module
replacements remain permitted. This check does not validate remote dependency
availability or require consumer retrieval.
ReleaseKit runs its selected Go commands with readonly module mode and
`GOPROXY=off`; acquire dependencies and build the tool before candidate
validation. Formatting and whitespace remain ordinary development checks in

View File

@@ -31,7 +31,8 @@ version supported for applications using ConfigKit.
Release validation runs the offline Go test suite once with the race detector,
then a full vet pass and package compilation for both Linux targets with cgo
disabled (`CGO_ENABLED=0`). macOS and CI are
disabled (`CGO_ENABLED=0`), followed by the selected committed-file checks
described in [publication](#4-publish-the-committed-candidate). macOS and CI are
not release gates; do not wait for or poll CI.
The vanity module path is the name applications put in Go imports. Source/tag
@@ -93,22 +94,18 @@ Prepare dependencies whether or not the minimum changes:
go mod download
```
Build the ReleaseKit version recorded in `go.mod` into temporary storage outside
the checkout.
Ensure `$TMPDIR` names a system temporary location, not the repository. These
commands never install another ReleaseKit version:
Run the module-selected ReleaseKit tool from the repository root. Go builds
and caches the executable; no temporary executable or launcher is needed.
These commands use the version recorded in `go.mod`:
```sh
releasekit_tmp=$(mktemp -d "${TMPDIR:-/tmp}/configkit-releasekit.XXXXXX")
releasekit_bin="$releasekit_tmp/releasekit"
go build -o "$releasekit_bin" go.maximumdirect.net/releasekit/cmd/releasekit
go list -m go.maximumdirect.net/releasekit
"$releasekit_bin" version
go tool releasekit version
```
This repository selects ReleaseKit v0.6.0. Use the compiled program above for
publication so its exit status and publication report reach you unchanged.
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
This repository selects ReleaseKit v0.8.0. `go tool releasekit` preserves the
tool's exit status and publication report; `go run` maps a nonzero child exit
status to 1. ReleaseKit's child Go commands
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
executable first on `PATH` through the publication command.
@@ -174,10 +171,10 @@ candidate to `main`, and invoke validation again.
## 4. Publish the committed candidate
Once publication is authorized and the candidate is on the publication remote's
`main`, run the compiled ReleaseKit program once to validate and publish it:
`main`, run the module-selected ReleaseKit tool once to validate and publish it:
```sh
"$releasekit_bin" publish vMAJOR.MINOR.PATCH
go tool releasekit publish vMAJOR.MINOR.PATCH
```
Replace the placeholder with the selected version. ReleaseKit checks the
@@ -186,14 +183,20 @@ It verifies the candidate on
`main`, and checks the release-note H1 for the product and version. It then
runs the selected race-enabled standard suite once with test-time vet disabled,
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
arm64. It publishes only the annotated tag for that candidate. Confirmed
arm64. It also checks the committed candidate for the forbidden token path,
filesystem replacements in module declarations, and local Markdown destinations
and supported heading fragments. Markdown links outside the checkout are
reported as out of scope without failing validation. These file checks inspect
the committed candidate, not uncommitted or ignored working-tree content.
It publishes only the annotated tag for that candidate. Confirmed
publication of the intended tag completes the release. The tag points to the
captured commit and uses the complete checked-in note as its annotation message.
Use the optional `check` command for diagnosis when useful. It validates without
publishing and is not required before `publish`, which performs its own checks:
```sh
"$releasekit_bin" check vMAJOR.MINOR.PATCH
go tool releasekit check vMAJOR.MINOR.PATCH
```
For `publish`, interpret the program's exit status as follows:
@@ -215,11 +218,11 @@ remote read-back or consumer retrieval; those checks remain optional.
## 5. Deliver the hosted release page
After source publication succeeds, run local hosted delivery from the repository
root. Keep the worktree clean and `HEAD` at the published tag's commit. Reuse the
compiled executable from step 1. For the first release using this workflow:
root. Keep the worktree clean and `HEAD` at the published tag's commit. Use the
module-selected tool as in step 1. For the first release using this workflow:
```sh
"$releasekit_bin" deliver v1.0.0
go tool releasekit deliver v1.0.0
```
For subsequent releases, substitute the selected version. The profile selects
@@ -247,8 +250,8 @@ separately.
A delivery problem leaves the successfully published source tag intact. Retry
delivery with the same immutable version after correcting the cause and
resolving any uncertain write. For a later retry, use a clean checkout of the
original tag, fetching that tag if needed. Build the module-selected CLI from
that checkout using step 1's tool-build commands, without changing the tagged
original tag, fetching that tag if needed. Use that checkout's module-selected
CLI, prepare its dependencies and tool as in step 1 without changing the tagged
module files, and make the token available there. For example, with the tag
available locally:
@@ -260,7 +263,7 @@ git switch --detach "$release_version"
After preparing the tool in that tagged checkout:
```sh
"$releasekit_bin" deliver "$release_version"
go tool releasekit deliver "$release_version"
```
Do not rerun `publish`, move a tag, or replace a hosted page to repair delivery.
@@ -269,53 +272,80 @@ version through the normal release procedure.
## 6. Recover from a failed or uncertain tag push
The executable reports the publication state, phase, version, candidate commit,
tag-object ID, effective endpoint, and error on standard error. After a failed
tag push, it makes one bounded check of the exact publication remote to find out
whether the tag arrived. Exit status 3
means the tag outcome remains unknown; do not blindly retry or move a tag.
ReleaseKit attempts to save publication evidence before pushing a tag and
reports the absolute record path, or a recording warning, separately from the
publication outcome. A recording failure does not undo a successful publication
or change its exit status. Confirmed publication still completes the source
release; hosted delivery remains a separate operation.
An annotated tag is a Git object containing the release note and a pointer to
the source commit. Its tag-object ID identifies that complete object. Checking
only the commit ID would not establish that the intended tag object arrived.
Records default to a private store under the repository's Git common directory,
shared by linked worktrees. `publish --record-dir DIR` can select another
location; relative paths resolve from the source repository root. Records can
contain exact endpoints, including credentials. Keep them private and remove
them when their recovery value expires. There is no new tracked storage,
credential-preflight gate, or required recording-success check.
For an uncertain result, use the exact endpoint (remote URL), candidate commit,
version, and tag-object ID from the report. Copy the captured endpoint into the variable
below using normal shell quoting. Check that Git still resolves it to itself,
then query the authoritative refs once:
After a failed push, ReleaseKit makes one bounded observation of the original
publication endpoint. If the outcome remains unknown, use the explicitly saved
record rather than rerunning publication. Copy the recovery command printed by
ReleaseKit, or use the module-selected tool:
```sh
publication_record='/absolute/path/from/the/publication/report.json'
go tool releasekit reconcile --record "$publication_record"
```
If the path was lost, discover local attempts without contacting a remote:
```sh
go tool releasekit publication-attempts
```
Select the record for the intended attempt from its reported version, candidate,
and tag evidence; discovery does not automatically choose the latest attempt.
For an alternate store, supply `publication-attempts --record-dir DIR`.
Both commands support optional `--json` output. Treat saved records as private
regardless of the CLI's credential-redacted diagnostic output.
Reconciliation observes the captured endpoint and exact tag object using current
supported Git transport settings. It does not load the current profile, require
the original `HEAD`, rerun candidate validation, change tags, or contact Gitea
hosted delivery. It needs no clean candidate checkout. Use ReleaseKit v0.8.0 or
another CLI supporting the saved record format, including format 2 where exact
non-UTF-8 endpoint/path bytes were retained.
| Reconciliation status | Meaning and next action |
| --- | --- |
| `0` | The intended tag object is published. The source release is complete; continue with hosted delivery from its clean tagged checkout. |
| `1` | The tag is currently absent or conflicting. Read the reported state. Investigate conflicts; absence alone does not prove that the version was never published. |
| `2` | The record cannot be read/decoded, or the invocation is invalid. Correct the path, access, CLI compatibility, or command. |
| `3` | Observation remains unresolved, including insufficient evidence or cancellation. Correct the transport or other reported cause before another explicit observation; do not blindly republish. |
These are recovery actions after failure, not read-back requirements after a
successful push. A failed observation leaves the result unresolved. Do not poll
CI or move a tag to settle uncertainty.
When no usable record exists (for example, storage failed or an older CLI made
the attempt), use the captured endpoint and exact tag-object ID from the original
report for a manual authoritative observation. Checking only the peeled commit
would not establish that the intended annotated tag object arrived. Do not
substitute current profile or local tag state for missing original evidence.
Only after establishing that a retained local tag was never published or used
by another release channel may it be removed before retrying publication:
```sh
authoritative='COPY_CAPTURED_ENDPOINT'
release_version='vMAJOR.MINOR.PATCH'
test "$(git ls-remote --get-url -- "$authoritative")" = "$authoritative" &&
git ls-remote --refs -- "$authoritative" refs/heads/main "refs/tags/$release_version"
```
A failed URL-equality check or remote query leaves the outcome unresolved.
Correct transport configuration before continuing; a rewritten destination
cannot establish absence at the captured endpoint.
Compare the `main` object ID with the reported candidate commit and the tag
object ID with the reported tag-object ID. `--refs` returns the annotated tag
object itself. A peeled commit is the source commit reached by following that
tag; it is a different identity. If the tag object matches, the
release is published. If the query fails, the result is conflicting, or the
endpoint or candidate differs, stop and resolve the state before retrying. If
the endpoint is reachable, the tag is confirmed absent, and `main` still equals
the reported candidate, remove a retained local tag only after confirming it
was not published or used by another channel; then rerun `publish`, which
revalidates the candidate:
```sh
git tag -d "$release_version"
"$releasekit_bin" publish "$release_version"
go tool releasekit publish "$release_version"
```
Replace the placeholder with the original attempt's version.
The next `publish` revalidates the candidate and publication guards. A conflicting
published tag must never be moved or reused. A defect in published source
requires a corrected commit, a new appropriate version, and a matching note.
A validation failure after the candidate reached `main` leaves an untagged
candidate there. Correct it with a new commit rather than rewriting or rolling
back `main`. A conflicting published tag must never be moved or reused. A
defect in published source requires a corrected commit, a new semantically
appropriate version, and a matching release note.
candidate there; correct it in a new commit rather than rewriting `main`.
## 7. Optional consumer verification

79
docs/releases/v1.1.0.md Normal file
View File

@@ -0,0 +1,79 @@
# ConfigKit v1.1.0
ConfigKit now accepts field-level resolution records inside lists and follows
registered records across position changes when reporting their provenance and
history. This release also standardizes more release validation on ReleaseKit.
## Field-level resolution in lists
Applications can normalize or derive a field inside a list and record that
field directly. For example, changing `/resources/0/path` can be covered by
`RecordNormalization` at that path, without recording the entire `/resources`
list when no other changes require coverage.
For ordinary lists of equal length, resolution auditing compares corresponding
positions recursively. Positions are addresses; ConfigKit does not infer an
element's identity from similar values. A length change still requires a record
for the list or an ancestor.
For collections registered through `Definition.SequenceIdentities`, ConfigKit
matches records between the resolution-entry and final snapshots when both
endpoints have valid, unique identities. Field edits can be recorded individually.
Changes to identity order or membership still require list or ancestor coverage;
changing an identity is treated as removal and addition. When matching is
unavailable, a changed registered collection is audited as a whole. Existing
final identity validation remains in place, allowing resolution to repair entry
values.
See [recorded application resolution](../config.md#recorded-application-resolution)
for the complete rules.
## Matched history and inspection
For eligible registered collections, inspection at a surviving record's final
position follows its own entry evidence, including earlier values, input
explicitness, and source attribution. A record no longer inherits the evidence
of a different record that previously occupied its final position. Original
source coordinates remain intact, while reported operation paths follow the
matched final record.
Collection inspection retains removed records' evidence. Removed fields of
surviving moved records remain available through metadata history and
explanation, including deep inherited fields. Sensitivity propagation protects
matched and removed evidence and recorded dependencies; detached before/after
snapshots remain conservatively masked.
Correspondence lookup, sensitivity propagation, and resolution-change ordering
reuse indexes, a work queue, and prepared pointer strings to avoid repeated
prefix construction, full-map passes, and formatting during sorting. No
end-to-end performance improvement is claimed.
## Compatibility
This is a backward-compatible extension of resolution auditing and inspection.
Existing callbacks that record a whole list remain supported. Records and helper
targets for surviving values refer to final positions; helpers do not follow
later moves automatically. `Resolution.EntryInfo` and `Resolution.IsExplicit`
retain their fixed entry-snapshot meaning.
Public effective-configuration diffs continue to report list changes atomically.
Source composition, collection merge operations, configuration syntax, and public
path addressing are unchanged. No new public Go symbols are required.
The consumer minimum remains Go 1.27.0, and the YAML dependency is unchanged.
Linux amd64 and arm64 remain release-validated; macOS remains best-effort.
## Release tooling
ReleaseKit advances from v0.6.0 to v0.8.0. The runbook uses the module-selected
`go tool releasekit` command and documents saved publication attempts and
reconciliation after failed or uncertain tag pushes.
ReleaseKit now checks committed Markdown links and rejects filesystem dependency
replacements during candidate validation. The custom documentation-link Go test
is removed, so automated link feedback occurs during candidate validation rather
than ordinary Go tests. Source publication and hosted Gitea delivery remain
separate outcomes; CI remains outside release gates. See the
[release procedure](../release.md).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/oss/attribution.md)

View File

@@ -1,151 +0,0 @@
package configkit
import (
"bufio"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"unicode"
)
var markdownLinkPattern = regexp.MustCompile(`(?m)(?:^|[[:space:]])\[[^\]]*\]\(([^)[:space:]]+)`)
func TestLocalDocumentationLinks(t *testing.T) {
repository, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
var failures []string
err = filepath.WalkDir(repository, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if entry.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".md" {
return nil
}
content, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(content), -1) {
target := strings.Trim(match[1], "<>")
if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") {
continue
}
filePart, fragment, _ := strings.Cut(target, "#")
decoded, decodeErr := url.PathUnescape(filePart)
if decodeErr != nil {
failures = append(failures, fmt.Sprintf("%s: invalid escaped link %q", relativePath(repository, path), target))
continue
}
linked := path
if decoded != "" {
linked = filepath.Clean(filepath.Join(filepath.Dir(path), filepath.FromSlash(decoded)))
}
_, statErr := os.Stat(linked)
outside := linked != repository && !strings.HasPrefix(linked, repository+string(filepath.Separator))
if os.IsNotExist(statErr) && outside && optionalSiblingSource(repository, linked) {
continue // Recorded sibling-source links are optional in isolated clones.
}
if statErr != nil {
failures = append(failures, fmt.Sprintf("%s: missing local link %q", relativePath(repository, path), target))
continue
}
if fragment != "" && filepath.Ext(linked) == ".md" && !markdownHasFragment(linked, fragment) {
failures = append(failures, fmt.Sprintf("%s: missing fragment in %q", relativePath(repository, path), target))
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(failures) != 0 {
t.Fatalf("documentation link failures:\n%s", strings.Join(failures, "\n"))
}
}
func optionalSiblingSource(repository, linked string) bool {
relative, err := filepath.Rel(filepath.Dir(repository), linked)
if err != nil || relative == "." || filepath.IsAbs(relative) {
return false
}
parts := strings.Split(filepath.ToSlash(relative), "/")
if len(parts) == 0 {
return false
}
switch strings.ToLower(parts[0]) {
case "narratio", "notarius", "weatherreporter", "seriatim":
return true
default:
return false
}
}
func markdownHasFragment(path, wanted string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
wanted, err = url.PathUnescape(wanted)
if err != nil {
return false
}
seen := make(map[string]int)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") {
continue
}
heading := strings.TrimSpace(strings.TrimLeft(line, "#"))
base := markdownFragment(heading)
count := seen[base]
seen[base]++
fragment := base
if count > 0 {
fragment = fmt.Sprintf("%s-%d", base, count)
}
if fragment == wanted {
return true
}
}
return false
}
func markdownFragment(heading string) string {
var result strings.Builder
previousSpace := false
for _, character := range strings.ToLower(heading) {
switch {
case unicode.IsLetter(character), unicode.IsDigit(character), character == '_', character == '-':
result.WriteRune(character)
previousSpace = false
case unicode.IsSpace(character):
if result.Len() > 0 && !previousSpace {
result.WriteByte('-')
previousSpace = true
}
}
}
return strings.TrimRight(result.String(), "-")
}
func relativePath(root, path string) string {
relative, err := filepath.Rel(root, path)
if err != nil {
return path
}
return filepath.ToSlash(relative)
}

View File

@@ -0,0 +1,223 @@
package configkit_test
import (
"errors"
"strings"
"testing"
"go.maximumdirect.net/configkit"
)
type resolutionSequenceItem struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type resolutionSequenceConfig struct {
Groups map[string][]resolutionSequenceItem `yaml:"groups"`
}
func TestPublicResolutionOrdinaryListField(t *testing.T) {
type config struct {
Items []resolutionSequenceItem `yaml:"items"`
}
definition := configkit.Definition[config]{Resolve: func(value *config, r *configkit.Resolution) error {
configkit.Normalize(r, configkit.Keys("items").Index(0).Key("value"), &value.Items[0].Value, "changed", "normalize")
return nil
}}
result, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a, value: old}]\n")), configkit.Options[config]{Definition: definition})
if err != nil || result.Config.Items[0].Value != "changed" {
t.Fatalf("result = %#v, %v", result, err)
}
}
func TestPublicRegisteredResolutionHistoryAndOwnership(t *testing.T) {
selector := configkit.SequenceSelector{configkit.SequenceLiteralKey("groups"), configkit.SequenceAnyMappingKey()}
path := configkit.Keys("groups", "active")
entryFixed := false
definition := configkit.Definition[resolutionSequenceConfig]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityMatching(selector, "id")},
Resolve: func(value *resolutionSequenceConfig, r *configkit.Resolution) error {
entryFixed = r.IsExplicit(path.Index(0).Key("value")) && !r.IsExplicit(path.Index(1).Key("value"))
items := value.Groups["active"]
items[0], items[1] = items[1], items[0]
items[1].Value = "normalized"
value.Groups["active"] = items
r.RecordNormalization(path, "reorder")
r.RecordNormalization(path.Index(1).Key("value"), "normalize")
return nil
},
}
source := configkit.Bytes("records", []byte("groups:\n active:\n - id: a\n value: original\n - id: b\n"))
load := func() *configkit.Result[resolutionSequenceConfig] {
result, err := configkit.Load(source, configkit.Options[resolutionSequenceConfig]{Definition: definition})
if err != nil {
t.Fatal(err)
}
return result
}
result := load()
if !entryFixed || result.Config.Groups["active"][1].Value != "normalized" {
t.Fatalf("result = %#v", result.Config)
}
info, ok := result.Metadata().Lookup(path.Index(1).Key("value"))
if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerResolution {
t.Fatalf("metadata = %#v, %t", info, ok)
}
explanation, ok := result.Explain(path.Index(1).Key("value"))
if !ok {
t.Fatal("missing explanation")
}
found := false
for _, change := range explanation.History {
if change.Origin.Layer == configkit.LayerResolution && change.Before.YAML == "original\n" && change.After.YAML == "normalized\n" {
found = true
}
}
if !found {
t.Fatalf("history = %#v", explanation.History)
}
other := load()
result.Config.Groups["active"][1].Value = "mutated"
if len(explanation.History) != 0 {
explanation.History[0].After.YAML = "mutated"
}
again, _ := result.Explain(path.Index(1).Key("value"))
if strings.Contains(again.Effective.YAML, "mutated") || other.Config.Groups["active"][1].Value != "normalized" {
t.Fatal("inspection or independent load changed")
}
plain, err := configkit.Load[resolutionSequenceConfig](source)
if err != nil {
t.Fatal(err)
}
differences, err := configkit.Diff(plain, result)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/groups/active" {
t.Fatalf("diff = %#v, %v", differences, err)
}
layered, err := configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[resolutionSequenceConfig]{Definition: definition})
if err != nil || layered.Config.Groups["active"][1].Value != other.Config.Groups["active"][1].Value {
t.Fatalf("layered = %#v, %v", layered, err)
}
}
func TestPublicRegisteredMoveRequiresContainerRecord(t *testing.T) {
type config struct {
Items []resolutionSequenceItem `yaml:"items"`
}
_, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a}, {id: b}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Resolve: func(value *config, r *configkit.Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(configkit.Keys("items").Index(1).Key("id"), "record field")
return nil
},
}})
var failure *configkit.LoadError
if !errors.As(err, &failure) {
t.Fatalf("error = %v", err)
}
for _, diagnostic := range failure.Diagnostics() {
if diagnostic.Code == configkit.CodeResolveUnrecordedChange && diagnostic.Path.String() == "/items" {
return
}
}
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
}
func TestPublicRegisteredRemovalKeepsSurvivorHistory(t *testing.T) {
type config struct {
Items []resolutionSequenceItem `yaml:"items"`
}
definition := configkit.Definition[config]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Resolve: func(value *config, r *configkit.Resolution) error {
value.Items = value.Items[1:]
r.RecordNormalization(configkit.Keys("items"), "remove first")
return nil
},
}
result, err := configkit.Load(configkit.Bytes("items", []byte("items:\n - id: reports\n value: report\n - id: invoices\n value: invoice\n")), configkit.Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Explain(configkit.Keys("items").Index(0).Key("value"))
if !ok {
t.Fatal("missing survivor")
}
for _, change := range explanation.History {
if change.Origin.Layer == configkit.LayerMain && change.After.YAML == "report\n" {
t.Fatalf("wrong lineage = %#v", explanation.History)
}
}
container, ok := result.Explain(configkit.Keys("items"))
if !ok {
t.Fatal("missing collection explanation")
}
found := false
for _, change := range container.History {
if change.Origin.Layer == configkit.LayerResolution && strings.Contains(change.Before.YAML, "reports") {
found = true
}
}
if !found {
t.Fatalf("missing removal evidence = %#v", container.History)
}
if _, ok := result.Explain(configkit.Keys("items").Index(1)); !ok {
t.Fatal("missing historical position")
}
}
func TestPublicYAMLIdentityRemainsPositionalDuringResolution(t *testing.T) {
type config struct {
Items []resolutionSequenceItem `yaml:"items"`
}
result, err := configkit.Load(configkit.Bytes("items", []byte("config: {sequences: [{destination: [items], key: id}]}\nitems: [{id: a, value: first}, {id: b, value: second}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(value *config, r *configkit.Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(configkit.Keys("items"), "reorder")
return nil
}}})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Explain(configkit.Keys("items").Index(0).Key("value"))
if !ok {
t.Fatal("missing explanation")
}
for _, change := range explanation.History {
if change.Origin.Layer == configkit.LayerResolution && change.Before.YAML == "first\n" && change.After.YAML == "second\n" {
return
}
}
t.Fatalf("positional history = %#v", explanation.History)
}
func TestPublicMovedSensitiveRecordIsHidden(t *testing.T) {
type config struct {
Items []resolutionSequenceItem `yaml:"items"`
}
result, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a, value: FACADE_PRIVATE_CANARY}, {id: b, value: public}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Sensitive: func(path configkit.Path) bool { return path.String() == "/items/0/value" },
Resolve: func(value *config, r *configkit.Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(configkit.Keys("items"), "reorder")
return nil
},
}})
if err != nil {
t.Fatal(err)
}
for _, path := range []configkit.Path{configkit.Keys("items"), configkit.Keys("items").Index(1).Key("value")} {
explanation, ok := result.Explain(path)
if !ok {
t.Fatalf("missing explanation %s", path.String())
}
if strings.Contains(explanation.Effective.YAML, "FACADE_PRIVATE_CANARY") {
t.Fatal("effective leak")
}
for _, change := range explanation.History {
if strings.Contains(change.Before.YAML, "FACADE_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "FACADE_PRIVATE_CANARY") {
t.Fatal("history leak")
}
}
}
}

2
go.mod
View File

@@ -6,7 +6,7 @@ require go.yaml.in/yaml/v4 v4.0.0-rc.6
require (
github.com/yuin/goldmark/v2 v2.1.6 // indirect
go.maximumdirect.net/releasekit v0.6.0 // indirect
go.maximumdirect.net/releasekit v0.8.0 // indirect
golang.org/x/mod v0.40.0 // indirect
)

4
go.sum
View File

@@ -1,7 +1,7 @@
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
go.maximumdirect.net/releasekit v0.6.0 h1:wbzMeQGeLjXigBSN9j9iJLf/CC3htjzt9iEwubezgy0=
go.maximumdirect.net/releasekit v0.6.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
go.maximumdirect.net/releasekit v0.8.0 h1:hSaHPbUI7tAhnolfbu7UMj2fvmm9FGBY+jhkygdbxR0=
go.maximumdirect.net/releasekit v0.8.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=

View File

@@ -46,6 +46,7 @@ type Explanation struct {
// or historical path, hiding sensitive information. History follows processing
// order: inheritance appears after source combination and before the transition
// to typed values. Unknown and malformed paths return false.
// Surviving registered records use their matched resolution-entry history.
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if r == nil {
return Explanation{}, false

View File

@@ -2,8 +2,9 @@ package engine
import (
"cmp"
"go.maximumdirect.net/configkit/internal/document"
"slices"
"go.maximumdirect.net/configkit/internal/document"
)
type documentDeltaKind uint8
@@ -24,6 +25,10 @@ type documentDelta struct {
func compareConfigurationValues(before, after *document.Node) []documentDelta {
var deltas []documentDelta
compareConfigurationValue(before, after, Path{}, &deltas)
return sortDocumentDeltas(deltas)
}
func sortDocumentDeltas(deltas []documentDelta) []documentDelta {
type orderedDelta struct {
delta documentDelta
pointer string

View File

@@ -49,6 +49,11 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
return Explanation{}, false
}
selections := model.indexReplaySelections(selected)
coordinate := model.queryCoordinates(path)
entryPath := coordinate.entry
if !coordinate.hasEntry {
entryPath = Path{invalid: true}
}
explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, model.documentLookup)}
if info, ok := r.metadata.Lookup(path); ok {
explanation.Origin = cloneOrigin(info.Origin)
@@ -57,7 +62,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
working := document.Clone(model.baseline)
if baseline, found := selections.byPayload[historyPayloadRef{phase: historyPhaseBaseline, id: 0}]; found && baseline.after {
explanation.History = append(explanation.History, ValueChange{
After: valueSnapshot(working, path, r.secrets),
After: valueSnapshot(working, entryPath, r.secrets),
Origin: cloneOrigin(baseline.attribution.origin),
})
}
@@ -67,13 +72,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
continue
}
application := payload.application
before := valueSnapshot(working, path, r.secrets)
before := valueSnapshot(working, entryPath, r.secrets)
applyHistoryApplication(&working, application, replayIndex)
selected, relevant := selections.application(historyPayloadRef{phase: historyPhaseSources, id: payloadID})
if !relevant {
continue
}
after := valueSnapshot(working, path, r.secrets)
after := valueSnapshot(working, entryPath, r.secrets)
if !before.Present && !after.Present {
continue
}
@@ -83,20 +88,20 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
Origin: cloneOrigin(selected.attribution.origin),
})
}
appendInheritanceExplanation(&explanation, &working, path, model, selections, r.secrets)
appendInheritanceExplanation(&explanation, &working, entryPath, model, selections, r.secrets)
// Intermediate input replacements do not permanently suppress defaults:
// decoding applies them to the final input tree. Show that reconciliation
// explicitly before typed CLI contributions and domain resolution.
if checkpoint := model.preCLICheckpoint; checkpoint != nil {
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePreCLI, id: 0}]
replayed, _ := documentAtPath(working, path)
decoded, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup)
replayed, _ := documentAtPath(working, entryPath)
decoded, _ := documentAtPathWithLookup(checkpoint.root, entryPath, model.documentLookup)
if selected && !configurationValuesEqual(replayed, decoded) {
origin, reasons := checkpointExplanationAttribution(selection)
explanation.History = append(explanation.History, ValueChange{
Before: valueSnapshot(working, path, r.secrets),
After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup),
Before: valueSnapshot(working, entryPath, r.secrets),
After: valueSnapshotWithLookup(checkpoint.root, entryPath, r.secrets, model.documentLookup),
Origin: origin,
Reasons: reasons,
})
@@ -109,13 +114,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
continue
}
applicationRef := historyPayloadRef{phase: historyPhaseCLI, id: payloadID}
before := valueSnapshot(working, path, r.secrets)
before := valueSnapshot(working, entryPath, r.secrets)
applyHistoryApplication(&working, payload.application, replayIndex)
selected, relevant := selections.application(applicationRef)
if !relevant {
continue
}
after := valueSnapshot(working, path, r.secrets)
after := valueSnapshot(working, entryPath, r.secrets)
if !before.Present && !after.Present {
continue
}
@@ -127,13 +132,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
}
if checkpoint := model.postCLICheckpoint; checkpoint != nil {
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePostCLI, id: 0}]
replayed, _ := documentAtPath(working, path)
effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup)
replayed, _ := documentAtPath(working, entryPath)
effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, entryPath, model.documentLookup)
if selected && !configurationValuesEqual(replayed, effectiveInput) {
origin, reasons := checkpointExplanationAttribution(selection)
explanation.History = append(explanation.History, ValueChange{
Before: valueSnapshot(working, path, r.secrets),
After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup),
Before: valueSnapshot(working, entryPath, r.secrets),
After: valueSnapshotWithLookup(checkpoint.root, entryPath, r.secrets, model.documentLookup),
Origin: origin,
Reasons: reasons,
})
@@ -147,7 +152,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
continue
}
covering := model.resolutionPayload.snapshots[group.coveringRecordID]
before := valueSnapshot(working, path, r.secrets)
before := valueSnapshot(working, entryPath, r.secrets)
replaceHistoryPath(&working, group.change.Path, covering.after)
after := valueSnapshot(working, path, r.secrets)
if !before.Present && !after.Present {

View File

@@ -12,15 +12,16 @@ type resolutionReplayGroup struct {
}
type historyResolutionPayload struct {
snapshots []resolutionSnapshot
replayGroups []resolutionReplayGroup
snapshots []resolutionSnapshot
replayGroups []resolutionReplayGroup
correspondence resolutionCorrespondence
}
// observeResolutionEvidence retains audited records in producer order and
// indexes the concrete shapes in every original before/after subtree. Snapshot
// roots are already detached by runResolution and are shared with the replay
// group that refers to their covering record.
func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionSnapshot) {
func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionSnapshot, correspondence ...resolutionCorrespondence) {
builder.requireOpen()
if len(snapshots) == 0 {
return
@@ -36,10 +37,14 @@ func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionS
snapshots: make([]resolutionSnapshot, len(snapshots)),
replayGroups: coalescedResolutionSnapshots(snapshots),
}
if len(correspondence) != 0 {
payload.correspondence = correspondence[0].clone()
}
attributions := make([]historyAttributionRef, len(snapshots))
lookup := buildDocumentLookup(builder.scratch)
for recordID, snapshot := range snapshots {
snapshot.change = cloneChange(snapshot.change)
snapshot.entryPath = snapshot.entryPath.clone()
payload.snapshots[recordID] = snapshot
ref := builder.nextPayload(historyPhaseResolution)
@@ -48,8 +53,12 @@ func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionS
}
attribution := builder.addAttribution(snapshot.change.Origin, snapshot.change.Reasons, snapshot.change.Dependencies)
attributions[recordID] = attribution
beforePath, beforeFound := historyConcretePathAt(builder.scratch, snapshot.change.Path, lookup)
if beforeFound {
entryPath, hasEntry := snapshot.change.Path, true
if len(correspondence) != 0 {
entryPath, hasEntry = snapshot.entryPath, snapshot.hasEntry
}
beforePath, beforeFound := historyConcretePathAt(builder.scratch, entryPath, lookup)
if hasEntry && beforeFound {
observeResolutionSubtree(builder, snapshot.before, beforePath, snapshot.change.Path, ref, attribution, true, false)
}
}

View File

@@ -26,21 +26,43 @@ type historySensitivityCandidate struct {
classify bool
}
type historyQueryCoordinates struct {
final Path
entry Path
hasEntry bool
}
func (model *historyModel) queryCoordinates(path Path) historyQueryCoordinates {
coordinate := historyQueryCoordinates{final: path.clone(), entry: path.clone(), hasEntry: true}
if model != nil && model.resolutionPayload != nil {
coordinate.entry, coordinate.hasEntry = model.resolutionPayload.correspondence.entryPath(path)
}
return coordinate
}
func (model *historyModel) selectHistory(path Path) []historySelection {
if model == nil || !path.valid() || !model.hasObservedShape(path) {
if model == nil || !path.valid() {
return nil
}
coordinate := model.queryCoordinates(path)
if !model.hasObservedShape(path) && (!coordinate.hasEntry || !model.hasObservedShape(coordinate.entry)) {
return nil
}
candidateSet := make(map[int]struct{})
for _, observationID := range model.observationsByPointer[path.String()] {
candidateSet[observationID] = struct{}{}
collect := func(query Path) {
for _, observationID := range model.observationsByPointer[query.String()] {
candidateSet[observationID] = struct{}{}
}
model.affectedPathIndex.visitDescendants(query.String(), func(observationID int) { candidateSet[observationID] = struct{}{} })
}
collect(path)
if coordinate.hasEntry {
collect(coordinate.entry)
}
// Producers register all affected before/after paths. Exact facts cover
// ancestor replacements; descendants cover operations within an enclosing
// query. Logical operations are still deduplicated by payload below.
model.affectedPathIndex.visitDescendants(path.String(), func(observationID int) {
candidateSet[observationID] = struct{}{}
})
candidates := make([]int, 0, len(candidateSet))
for observationID := range candidateSet {
candidates = append(candidates, observationID)
@@ -54,18 +76,30 @@ func (model *historyModel) selectHistory(path Path) []historySelection {
if observation.role == historyRoleInventory {
continue
}
query := path
if observation.phase != historyPhaseResolution {
if !coordinate.hasEntry {
continue
}
query = coordinate.entry
} else if observation.beforePresent && !observation.afterPresent {
if !coordinate.hasEntry {
continue
}
query = coordinate.entry
}
reportedPath := observation.reportedPath
attributionRef := observation.attributionRef
if observation.role == historyRoleContribution {
if !historyObservationMatchesQuery(path, observation.path) {
if !historyObservationMatchesQuery(query, observation.path) {
continue
}
application, found := model.applicationPayload(observation.payload)
if !found {
continue
}
if pathContains(observation.path, path) && len(observation.path.segments) < len(path.segments) {
local, ok := historyApplicationLocalPath(application, path)
if pathContains(observation.path, query) && len(observation.path.segments) < len(query.segments) {
local, ok := historyApplicationLocalPath(application, query)
if !ok {
continue
}
@@ -73,12 +107,17 @@ func (model *historyModel) selectHistory(path Path) []historySelection {
continue
}
}
if operationPath, relevant := applicationOperationPath(application, path, model.documentLookup); relevant {
if operationPath, relevant := applicationOperationPath(application, query, model.documentLookup); relevant {
reportedPath = operationPath
}
} else if !historyObservationMatchesQuery(path, observation.path) {
} else if !historyObservationMatchesQuery(query, observation.path) {
continue
}
if observation.phase != historyPhaseResolution && query.String() != path.String() {
if translated, matched := model.resolutionPayload.correspondence.finalPath(reportedPath); matched {
reportedPath = translated
}
}
selectionIndex, found := selectionByPayload[observation.payload]
if !found {
selectionIndex = len(selections)

View File

@@ -317,7 +317,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
return nil, accumulator.loadError()
}
postDecode := preparePostDecodeOverrideContext(input, decoded, assigned)
resolved, diagnostics := resolveLoadInput(schema, request.definition, input, postDecode)
resolved, diagnostics := resolveLoadInput(schema, request.definition, input, postDecode, request.sequencePolicy)
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()

View File

@@ -141,6 +141,15 @@ func cloneMetadata(metadata Metadata) Metadata {
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entry resolutionInput) {
finalPaths := make(map[string]*document.Node)
indexDocument(resolved.final, Path{}, finalPaths)
fields := make(map[string]FieldInfo, len(finalPaths))
walkDocument(resolved.final, Path{}, func(path Path, _ *document.Node) {
if prior, matched := resolved.correspondence.entryPath(path); matched {
if info, found := entry.lookup(prior); found {
fields[path.String()] = cloneFieldInfo(info)
}
}
})
metadata.fields = fields
snapshotCoverage, snapshotPointers := newResolutionCoverageIndexFromSnapshots(resolved.snapshots)
var deltaCoverage pathPrefixIndex
snapshotIDs := make([]int, len(resolved.deltas))
@@ -163,7 +172,7 @@ func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entr
}
applyResolutionFieldOrigins(metadata.fields, resolved.snapshots, &deltaCoverage, snapshotIDs)
refreshResolutionFields(metadata.fields, finalPaths, resolved.final, entry, resolved.snapshots, snapshotCoverage)
refreshResolutionFields(metadata.fields, finalPaths, resolved.final, entry, resolved.snapshots, snapshotCoverage, resolved.correspondence)
}
func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolutionSnapshot, deltaIndex *pathPrefixIndex, snapshotIDs []int) {
@@ -192,7 +201,7 @@ func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolu
}
}
func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]*document.Node, final *document.Node, entry resolutionInput, snapshots []resolutionSnapshot, snapshotCoverage *resolutionCoverageIndex) {
func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]*document.Node, final *document.Node, entry resolutionInput, snapshots []resolutionSnapshot, snapshotCoverage *resolutionCoverageIndex, correspondence resolutionCorrespondence) {
for pointer := range fields {
if _, ok := finalPaths[pointer]; !ok {
delete(fields, pointer)
@@ -200,7 +209,9 @@ func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]
}
walkDocument(final, Path{}, func(path Path, node *document.Node) {
pointer := path.String()
entryInfo, entryFound := entry.lookup(path)
prior, matched := correspondence.entryPath(path)
entryInfo, entryFound := entry.lookup(prior)
entryFound = entryFound && matched
if info, ok := fields[pointer]; ok {
info.Explicit = entryFound && entryInfo.Explicit
info.Null = node.Kind == document.Null

View File

@@ -43,7 +43,7 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
return decoded, nil
}
func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, prepared postDecodeOverrideContext) (resolvedLoad[T], []Diagnostic) {
func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, prepared postDecodeOverrideContext, policy sequencePolicy) (resolvedLoad[T], []Diagnostic) {
var diagnostics []Diagnostic
decoded := prepared.decoded
configuration := decoded.value.Interface().(T)
@@ -54,7 +54,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
var resolutionSnapshots []resolutionSnapshot
refreshReferenceInput := false
if definition.Resolve != nil {
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, referenceInput, prepared.resolution)
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, referenceInput, prepared.resolution, policy)
diagnostics = append(diagnostics, resolved.diagnostics...)
if hasErrors(resolved.diagnostics) {
return resolvedLoad[T]{}, diagnostics
@@ -62,7 +62,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
final = resolved.final
resolutionSnapshots = resolved.snapshots
if builder != nil && len(resolutionSnapshots) != 0 {
builder.observeResolutionEvidence(resolutionSnapshots)
builder.observeResolutionEvidence(resolutionSnapshots, resolved.correspondence)
}
applyResolutionMetadata(&metadata, resolved, prepared.resolution)
finalizeEffectiveMetadataPaths(&metadata, final)

View File

@@ -49,7 +49,7 @@ func (m Metadata) effectiveField(path Path) (FieldInfo, bool) {
// Unknown and invalid paths return nil.
func (m Metadata) History(path Path) []Change {
model := m.historyEvidence()
if model == nil || !model.hasObservedShape(path) {
if model == nil {
return nil
}
selections := model.selectHistory(path)

View File

@@ -292,7 +292,7 @@ func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
value.Items[0] = "normalized"
resolution.RecordNormalization(Keys("items"), "normalized item")
return nil
}, schema, decoded, document, input)
}, schema, decoded, document, input, sequencePolicy{})
if len(resolved.diagnostics) != 0 {
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
}

View File

@@ -162,6 +162,13 @@ func documentWithMetadataLocations(root *document.Node, metadata Metadata, fallb
}
var lookup *documentLookup
fallbackLocation := func(path Path) (document.Location, bool) {
if model := metadata.historyEvidence(); model != nil && model.resolutionPayload != nil {
var matched bool
path, matched = model.resolutionPayload.correspondence.entryPath(path)
if !matched {
return document.Location{}, false
}
}
if lookup == nil {
lookup = buildDocumentLookup(fallbackRoot)
}

View File

@@ -87,7 +87,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
value.Count = 6
resolution.RecordNormalization(Keys("count"), "normalize count")
return nil
}}, input, preparePostDecodeOverrideContext(input, decoded, assigned))
}}, input, preparePostDecodeOverrideContext(input, decoded, assigned), sequencePolicy{})
if len(resolveDiagnostics) != 0 {
t.Fatalf("resolveLoadInput() diagnostics = %#v", resolveDiagnostics)
}
@@ -133,7 +133,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
resolution.RecordNormalization(Keys("api_key"), "select effective secret reference")
return nil
}
resolved := runResolution(&configuration, resolver, schema, prepared.decoded, prepared.referenceInput, prepared.resolution)
resolved := runResolution(&configuration, resolver, schema, prepared.decoded, prepared.referenceInput, prepared.resolution, sequencePolicy{})
if len(resolved.diagnostics) != 0 || configuration.Count != 5 || configuration.Derived != 42 {
t.Fatalf("resolution = %#v, configuration = %#v", resolved, configuration)
}

View File

@@ -59,16 +59,19 @@ func (record *resolutionRecord) addDependency(path Path) {
}
type resolutionSnapshot struct {
change Change
before *document.Node
after *document.Node
change Change
before *document.Node
after *document.Node
entryPath Path
hasEntry bool
}
type resolutionResult struct {
final *document.Node
deltas []documentDelta
snapshots []resolutionSnapshot
diagnostics []Diagnostic
final *document.Node
deltas []documentDelta
snapshots []resolutionSnapshot
diagnostics []Diagnostic
correspondence resolutionCorrespondence
}
// IsExplicit reports whether surviving direct or inherited input defined path.
@@ -289,7 +292,7 @@ func (state *resolutionState) addDiagnostic(code Code, path Path, message string
state.diagnostics = append(state.diagnostics, diagnostic)
}
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput) resolutionResult {
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput, policy sequencePolicy) resolutionResult {
state := &resolutionState{
active: true, schema: schema, document: input,
input: entry,
@@ -313,7 +316,8 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
}
deltas := compareConfigurationValues(decoded.final, after)
correspondence := buildResolutionCorrespondence(decoded.final, after, policy)
deltas := compareResolutionValues(decoded.final, after, correspondence)
ordered := orderedResolutionRecords(state.records)
coverage := newResolutionCoverageIndexFromRecords(ordered)
beforeLookup := buildDocumentLookup(decoded.final)
@@ -324,7 +328,9 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
}
}
for _, record := range ordered {
_, beforePresent := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
beforePath, hasEntry := correspondence.entryPath(record.path)
_, beforePresent := documentAtPathWithLookup(decoded.final, beforePath, beforeLookup)
beforePresent = beforePresent && hasEntry
afterNode, afterPresent := documentAtPathWithLookup(after, record.path, afterLookup)
if !beforePresent && !afterPresent {
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
@@ -340,7 +346,11 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
snapshots := make([]resolutionSnapshot, 0, len(ordered))
for _, record := range ordered {
beforeNode, _ := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
beforePath, hasEntry := correspondence.entryPath(record.path)
var beforeNode *document.Node
if hasEntry {
beforeNode, _ = documentAtPathWithLookup(decoded.final, beforePath, beforeLookup)
}
afterNode, _ := documentAtPathWithLookup(after, record.path, afterLookup)
snapshots = append(snapshots, resolutionSnapshot{
change: Change{
@@ -349,11 +359,11 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
Reasons: append([]string(nil), record.reasons...),
Dependencies: clonePaths(record.dependencies),
},
before: document.Clone(beforeNode), after: document.Clone(afterNode),
before: document.Clone(beforeNode), after: document.Clone(afterNode), entryPath: beforePath, hasEntry: hasEntry,
})
}
return resolutionResult{
final: after, deltas: deltas, snapshots: snapshots,
final: after, deltas: deltas, snapshots: snapshots, correspondence: correspondence,
diagnostics: canonicalDiagnostics(state.diagnostics),
}
}

View File

@@ -0,0 +1,60 @@
package engine
import "go.maximumdirect.net/configkit/internal/document"
// compareResolutionValues reports field-level changes at stable list positions.
func compareResolutionValues(before, after *document.Node, correspondence ...resolutionCorrespondence) []documentDelta {
var deltas []documentDelta
var compare func(*document.Node, *document.Node, Path)
compare = func(left, right *document.Node, path Path) {
if left == nil || right == nil || left.Kind != right.Kind {
compareConfigurationValue(left, right, path, &deltas)
return
}
switch left.Kind {
case document.Mapping:
leftEntries := make(map[string]*document.Node, len(left.Mapping))
for _, entry := range left.Mapping {
leftEntries[entry.Key] = entry.Value
}
for _, entry := range right.Mapping {
compare(leftEntries[entry.Key], entry.Value, path.Key(entry.Key))
delete(leftEntries, entry.Key)
}
for key, value := range leftEntries {
compare(value, nil, path.Key(key))
}
case document.Sequence:
if len(correspondence) != 0 {
if collection, registered := correspondence[0].collections[path.String()]; registered {
if !collection.eligible {
if !configurationValuesEqual(left, right) {
deltas = append(deltas, documentDelta{path: path.clone(), kind: documentReplaced})
}
return
}
if collection.structural {
deltas = append(deltas, documentDelta{path: path.clone(), kind: documentReplaced})
}
for finalIndex, entryIndex := range collection.finalToEntry {
if entryIndex >= 0 {
compare(left.Sequence[entryIndex], right.Sequence[finalIndex], path.Index(finalIndex))
}
}
return
}
}
if len(left.Sequence) != len(right.Sequence) {
deltas = append(deltas, documentDelta{path: path.clone(), kind: documentReplaced})
return
}
for index := range left.Sequence {
compare(left.Sequence[index], right.Sequence[index], path.Index(index))
}
default:
compareConfigurationValue(left, right, path, &deltas)
}
}
compare(before, after, Path{})
return sortDocumentDeltas(deltas)
}

View File

@@ -0,0 +1,711 @@
package engine
import (
"errors"
"strings"
"testing"
)
func TestResolutionAuditsOrdinaryListFields(t *testing.T) {
type item struct {
Count int `yaml:"count"`
Enabled bool `yaml:"enabled"`
Nested []*int `yaml:"nested"`
}
type config struct {
Items []item `yaml:"items"`
}
for _, test := range []struct {
name string
other, resize bool
wantPath string
}{
{name: "covered field"},
{name: "uncovered field", other: true, wantPath: "/items/0/enabled"},
{name: "length change", resize: true, wantPath: "/items"},
} {
t.Run(test.name, func(t *testing.T) {
result, err := testLoad(Bytes("items", []byte("items: [{count: 3, enabled: true, nested: [null]}]\n")), Options[config]{Definition: Definition[config]{
Resolve: func(value *config, r *Resolution) error {
value.Items[0].Count = 0
r.RecordNormalization(Keys("items").Index(0).Key("count"), "clear count")
if test.other {
value.Items[0].Enabled = false
}
if test.resize {
value.Items = append(value.Items, item{})
}
return nil
},
}})
if test.wantPath == "" {
if err != nil || result.Config.Items[0].Count != 0 {
t.Fatalf("result = %#v, error = %v", result, err)
}
return
}
var failure *LoadError
if !errors.As(err, &failure) {
t.Fatalf("error = %v", err)
}
found := false
for _, diagnostic := range failure.Diagnostics() {
if diagnostic.Code == CodeResolveUnrecordedChange && diagnostic.Path.String() == test.wantPath {
found = true
}
}
if !found {
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
}
})
}
}
func TestResolutionComparisonKeepsDiffAtomic(t *testing.T) {
before := parseCompositionDocument(t, "before", "items: [{values: [true, null]}]")
after := parseCompositionDocument(t, "after", "items: [{values: [false, 0]}]")
deltas := compareResolutionValues(before, after)
if len(deltas) != 2 || deltas[0].path.String() != "/items/0/values/0" || deltas[1].path.String() != "/items/0/values/1" {
t.Fatalf("resolution deltas = %#v", deltas)
}
atomic := compareConfigurationValues(before, after)
if len(atomic) != 1 || atomic[0].path.String() != "/items" {
t.Fatalf("atomic deltas = %#v", atomic)
}
}
func TestResolutionAuditsRegisteredCollectionChanges(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
for _, test := range []struct {
name string
move, container, rename bool
wantPath string
}{
{name: "field edit"},
{name: "uncovered move", move: true, wantPath: "/items"},
{name: "covered move", move: true, container: true},
{name: "identity replacement", rename: true, wantPath: "/items"},
} {
t.Run(test.name, func(t *testing.T) {
definition := Definition[config]{Resolve: func(value *config, r *Resolution) error {
value.Items[0].Value = "changed"
if test.move {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
}
if test.rename {
value.Items[0].ID = "new"
}
index := 0
if test.move {
index = 1
}
r.RecordNormalization(Keys("items").Index(index).Key("value"), "change value")
if test.container {
r.RecordNormalization(Keys("items"), "reorder")
}
return nil
}}
definition = DefinitionWithSequenceIdentityRegistrations(definition, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("items", []byte("items: [{id: a, value: old}, {id: b, value: other}]\n")), Options[config]{Definition: definition})
if test.wantPath == "" {
if err != nil {
t.Fatal(err)
}
if test.move {
correspondence := result.history.resolutionPayload.correspondence
entry, ok := correspondence.entryPath(Keys("items").Index(1).Key("value"))
if !ok || entry.String() != "/items/0/value" {
t.Fatalf("retained entry = %s, %t", entry.String(), ok)
}
for _, snapshot := range result.history.resolutionPayload.snapshots {
if snapshot.change.Path.String() == "/items/1/value" && (snapshot.before == nil || snapshot.before.Text != "old" || !snapshot.hasEntry || snapshot.entryPath.String() != "/items/0/value") {
t.Fatalf("paired before = %#v", snapshot.before)
}
if snapshot.before != nil {
found := false
for _, root := range result.history.documentRoots() {
if root == snapshot.before {
found = true
}
}
if !found {
t.Fatal("paired snapshot omitted from retained roots")
}
}
}
}
return
}
var failure *LoadError
if !errors.As(err, &failure) {
t.Fatalf("error = %v", err)
}
found := false
for _, diagnostic := range failure.Diagnostics() {
if diagnostic.Code == CodeResolveUnrecordedChange && diagnostic.Path.String() == test.wantPath {
found = true
}
}
if !found {
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
}
})
}
}
func TestRegisteredResolutionFallbackAndHelperTarget(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
registration := []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}
makeDefinition := func(resolve func(*config, *Resolution) error) Definition[config] {
return DefinitionWithSequenceIdentityRegistrations(Definition[config]{Defaults: func() config { return config{Items: []item{{ID: "a"}, {ID: "a"}}} }, Resolve: resolve}, registration)
}
_, err := testLoad(NoSource(), Options[config]{Definition: makeDefinition(func(value *config, r *Resolution) error {
value.Items[1].ID = "b"
r.RecordNormalization(Keys("items"), "repair identities")
return nil
})})
if err != nil {
t.Fatalf("repair rejected: %v", err)
}
_, err = testLoad(NoSource(), Options[config]{Definition: makeDefinition(func(*config, *Resolution) error { return nil })})
if err == nil {
t.Fatal("invalid final identities accepted")
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
Normalize(r, Keys("items").Index(0).Key("value"), &value.Items[0].Value, "changed", "normalize")
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(Keys("items"), "reorder")
return nil
}}, registration)
_, err = testLoad(Bytes("items", []byte("items: [{id: a, value: first}, {id: b, value: second}]\n")), Options[config]{Definition: definition})
var failure *LoadError
if !errors.As(err, &failure) {
t.Fatalf("helper error = %v", err)
}
found := false
for _, diagnostic := range failure.Diagnostics() {
if diagnostic.Code == CodeResolveInvalidTarget && diagnostic.Path.String() == "/items/0/value" {
found = true
}
}
if !found {
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
}
}
func TestRegisteredResolutionMetadataFollowsEntryIdentity(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Enabled bool `yaml:"enabled"`
Count int `yaml:"count"`
}
type config struct {
Items []item `yaml:"items"`
}
entryFixed := false
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
entryFixed = r.IsExplicit(Keys("items").Index(0).Key("enabled")) && !r.IsExplicit(Keys("items").Index(1).Key("enabled"))
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
value.Items = append(value.Items, item{ID: "new"})
r.RecordNormalization(Keys("items"), "reorder and add")
return nil
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: a, enabled: false}, {id: b, count: 0}]\n")), Options[config]{Definition: definition})
if err != nil || !entryFixed {
t.Fatalf("load = %#v, fixed entry = %t, error = %v", result, entryFixed, err)
}
for _, check := range []struct {
index int
key string
explicit bool
}{{0, "enabled", false}, {0, "count", true}, {1, "enabled", true}, {1, "count", false}, {2, "id", false}} {
info, ok := result.Metadata().Lookup(Keys("items").Index(check.index).Key(check.key))
if !ok || info.Explicit != check.explicit || info.Origin.Layer != LayerResolution {
t.Errorf("item %d %s = %#v, %t", check.index, check.key, info, ok)
}
}
}
func TestRegisteredResolutionValidationLocationFollowsMovedRecord(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
Resolve: func(value *config, r *Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(Keys("items"), "reorder")
return nil
},
Validate: func(_ *config, v *Validation) { v.Error(Keys("items").Index(0).Key("value"), "invalid") },
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
_, err := testLoad(Bytes("records", []byte("items:\n - id: a\n value: first\n - id: b\n value: second\n")), Options[config]{Definition: definition})
var failure *LoadError
if !errors.As(err, &failure) {
t.Fatalf("error = %v", err)
}
for _, diagnostic := range failure.Diagnostics() {
if diagnostic.Path.String() == "/items/0/value" && diagnostic.Primary != nil && diagnostic.Primary.Line == 5 {
return
}
}
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
}
func TestRegisteredResolutionHistoryFollowsMatchedSource(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
value.Items = append(value.Items, item{ID: "new", Value: "added"})
r.RecordNormalization(Keys("items"), "reorder and add")
return nil
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items:\n - id: a\n value: first\n - id: b\n value: second\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
for _, check := range []struct{ index, line int }{{0, 5}, {1, 3}} {
path := Keys("items").Index(check.index).Key("value")
if location, ok := result.history.latestHistoricalLocation(path); !ok || location.Line != check.line {
t.Errorf("historical location for %s = %#v, %t", path.String(), location, ok)
}
history := result.Metadata().History(path)
found := false
for _, change := range history {
for _, location := range change.Origin.Locations {
if location.Line == check.line {
found = true
} else if location.Line == 3 || location.Line == 5 {
t.Errorf("wrong record in %s: %#v", path.String(), history)
}
}
}
if !found {
t.Errorf("missing source line %d in %#v", check.line, history)
}
explanation, ok := result.Inspection.Explain(path)
if !ok {
t.Fatalf("missing explanation for %s", path.String())
}
want := "second\n"
if check.index == 1 {
want = "first\n"
}
foundValue := false
for _, change := range explanation.History {
if change.Origin.Layer == LayerMain && change.After.YAML == want {
foundValue = true
}
}
if !foundValue {
t.Errorf("missing matched input %q in %#v", want, explanation.History)
}
}
for _, change := range result.Metadata().History(Keys("items").Index(2).Key("value")) {
if change.Origin.Layer == LayerMain {
t.Fatalf("added record inherited input: %#v", change)
}
}
}
func TestMovedRecordExplanationIncludesInheritedValue(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(Keys("items"), "reorder")
return nil
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: parent, value: inherited}, {id: child, extends: {id: parent}}]\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Inspection.Explain(Keys("items").Index(0).Key("value"))
if !ok {
t.Fatal("missing explanation")
}
found := false
for _, change := range explanation.History {
if change.After.YAML == "inherited\n" {
found = true
}
}
if !found {
t.Fatalf("inherited history = %#v", explanation.History)
}
}
func TestMovedRecordExplanationIncludesCLIValue(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(Keys("items"), "reorder")
return nil
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(NoSource(), Options[config]{Definition: definition, Overrides: []Override{{
Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "a", "value": "first"}, map[string]any{"id": "b", "value": "second"}},
}}})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Inspection.Explain(Keys("items").Index(1).Key("value"))
if !ok {
t.Fatal("missing explanation")
}
for _, change := range explanation.History {
if change.Origin.Layer == LayerCLI && change.After.YAML == "first\n" {
return
}
}
t.Fatalf("CLI history = %#v", explanation.History)
}
func TestRegisteredResolutionExplainsRemovalAndReusedPositions(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
value.Items = value.Items[1:]
r.RecordNormalization(Keys("items"), "remove first")
return nil
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items:\n - id: reports\n value: report\n - id: invoices\n value: invoice\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
path := Keys("items").Index(0).Key("value")
explanation, ok := result.Inspection.Explain(path)
if !ok {
t.Fatal("survivor explanation missing")
}
found := false
for _, change := range explanation.History {
if change.Origin.Layer == LayerResolution {
if change.Before.YAML != "invoice\n" || change.After.YAML != "invoice\n" {
t.Fatalf("survivor operation = %#v", change)
}
found = true
}
if change.Origin.Layer == LayerMain && change.After.YAML == "report\n" {
t.Fatalf("removed occupant leaked into survivor: %#v", change)
}
}
if !found {
t.Fatal("missing collection operation")
}
container, ok := result.Inspection.Explain(Keys("items"))
if !ok {
t.Fatal("collection explanation missing")
}
found = false
for _, change := range container.History {
if change.Origin.Layer == LayerResolution && strings.Contains(change.Before.YAML, "reports") && !strings.Contains(change.After.YAML, "reports") {
found = true
}
}
if !found {
t.Fatalf("missing removed collection evidence: %#v", container.History)
}
mainEvidence := false
for _, change := range result.Metadata().History(Keys("items")) {
if change.Origin.Layer == LayerMain && len(change.Origin.Locations) != 0 {
mainEvidence = true
}
}
if !mainEvidence {
t.Fatal("collection history lost source attribution")
}
if _, ok := result.Inspection.Explain(Keys("items").Index(1)); !ok {
t.Fatal("absent historical position missing")
}
if _, ok := result.Inspection.Explain(Keys("items").Index(8)); ok {
t.Fatal("unknown position reported")
}
}
func TestRegisteredResolutionExplainsAddedAndChangedIdentities(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
for _, replace := range []bool{false, true} {
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
if replace {
value.Items[0] = item{ID: "new", Value: "added"}
} else {
value.Items = append([]item{{ID: "new", Value: "added"}}, value.Items...)
}
r.RecordNormalization(Keys("items"), "update collection")
return nil
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: old, value: original}]\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Inspection.Explain(Keys("items").Index(0).Key("value"))
if !ok {
t.Fatal("added explanation missing")
}
for _, change := range explanation.History {
if change.Origin.Layer == LayerMain || (change.Origin.Layer == LayerResolution && change.Before.Present) {
t.Fatalf("added record inherited previous value: %#v", explanation.History)
}
}
if !replace {
moved, ok := result.Inspection.Explain(Keys("items").Index(1).Key("value"))
if !ok {
t.Fatal("shifted survivor missing")
}
found := false
for _, change := range moved.History {
if change.Origin.Layer == LayerResolution && change.Before.YAML == "original\n" && change.After.YAML == "original\n" {
found = true
}
}
if !found {
t.Fatalf("shifted survivor = %#v", moved.History)
}
}
}
}
func TestRegisteredResolutionExplainsRemovedFieldOnMovedRecord(t *testing.T) {
type config struct {
Items []map[string]any `yaml:"items"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
delete(value.Items[0], "extra")
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(Keys("items"), "remove and reorder")
return nil
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: a, extra: own}, {id: b, extra: other}]\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Inspection.Explain(Keys("items").Index(1).Key("extra"))
if !ok {
t.Fatal("removed field explanation missing")
}
for _, change := range explanation.History {
if change.Origin.Layer == LayerResolution && change.Before.YAML == "own\n" && !change.After.Present {
return
}
}
t.Fatalf("removed field history = %#v", explanation.History)
}
func TestRegisteredResolutionTransfersSensitivityFromEntry(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
Derived string `yaml:"derived"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
Secret: func(path Path) bool { return path.String() == "/items/0/value" },
Resolve: func(value *config, r *Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
value.Derived = value.Items[1].Value
r.RecordNormalization(Keys("items"), "reorder")
r.RecordNormalization(Keys("derived"), "derive", Keys("items").Index(1).Key("value"))
return nil
},
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: confidential}, {id: b, value: visible}]\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("items").Index(0).Key("value"), Keys("items").Index(1).Key("value"), Keys("derived")} {
if !pathIsSecret(result.Inspection.secrets, path) {
t.Errorf("sensitivity lost at %s", path.String())
}
}
}
func TestRegisteredResolutionKeepsRemovedSensitivity(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
Secret: func(path Path) bool { return path.String() == "/items/0/value" },
Resolve: func(value *config, r *Resolution) error {
value.Items = value.Items[1:]
r.RecordNormalization(Keys("items"), "remove record")
return nil
},
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: confidential}, {id: b, value: visible}]\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
if !pathIsSecret(result.Inspection.secrets, Keys("items").Index(0).Key("value")) {
t.Fatal("removed historical sensitivity lost")
}
}
func TestMovedAndRemovedSensitiveValuesStayHidden(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
Derived string `yaml:"derived"`
}
for _, remove := range []bool{false, true} {
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
Secret: func(path Path) bool { return path.String() == "/items/1/value" },
Resolve: func(value *config, r *Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
value.Derived = value.Items[1].Value
r.RecordNormalization(Keys("items").Index(1).Key("value"), "retain value", Keys("derived"))
if remove {
value.Items = value.Items[:1]
}
r.RecordNormalization(Keys("items"), "update records")
r.RecordNormalization(Keys("derived"), "derive", Keys("items").Index(1).Key("value"))
return nil
},
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: PRIVATE_CANARY}, {id: b, value: other}]\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("items"), Keys("items").Index(0), Keys("items").Index(1), Keys("derived")} {
explanation, ok := result.Inspection.Explain(path)
if !ok {
continue
}
if strings.Contains(explanation.Effective.YAML, "PRIVATE_CANARY") {
t.Fatalf("effective leaked at %s", path.String())
}
for _, change := range explanation.History {
if strings.Contains(change.Before.YAML, "PRIVATE_CANARY") || strings.Contains(change.After.YAML, "PRIVATE_CANARY") {
t.Fatalf("history leaked at %s: %#v", path.String(), change)
}
}
}
var output strings.Builder
if err := result.Inspection.WriteEffective(&output); err != nil || strings.Contains(output.String(), "PRIVATE_CANARY") {
t.Fatalf("effective output leaked: %q, %v", output.String(), err)
}
}
}
func TestMovedDynamicTaggedValueStaysHidden(t *testing.T) {
type sensitive struct {
ID string `yaml:"id"`
Token string `yaml:"token" configkit:"secret"`
}
type config struct {
Items []any `yaml:"items"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
Defaults: func() config {
return config{Items: []any{sensitive{ID: "a", Token: "DYNAMIC_PRIVATE_CANARY"}, sensitive{ID: "b", Token: "other"}}}
},
Resolve: func(value *config, r *Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(Keys("items"), "reorder")
return nil
},
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(NoSource(), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("items"), Keys("items").Index(1).Key("token")} {
explanation, ok := result.Inspection.Explain(path)
if !ok {
t.Fatalf("missing explanation %s", path.String())
}
if strings.Contains(explanation.Effective.YAML, "DYNAMIC_PRIVATE_CANARY") {
t.Fatal("effective value leaked")
}
for _, change := range explanation.History {
if strings.Contains(change.Before.YAML, "DYNAMIC_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "DYNAMIC_PRIVATE_CANARY") {
t.Fatal("history leaked")
}
}
}
}
func TestMovedDependencyProtectsEarlierMatchedValue(t *testing.T) {
type item struct {
ID string `yaml:"id"`
Secret string `yaml:"secret"`
Value string `yaml:"value"`
}
type config struct {
Items []item `yaml:"items"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
Secret: func(path Path) bool { return path.String() == "/items/0/secret" },
Resolve: func(value *config, r *Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
value.Items[0].Value = value.Items[1].Secret
r.RecordNormalization(Keys("items"), "reorder")
r.RecordNormalization(Keys("items").Index(0).Key("value"), "derive", Keys("items").Index(1).Key("secret"))
return nil
},
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: a, secret: HIDDEN_A, value: public}, {id: b, value: EARLIER_PRIVATE}]\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Inspection.Explain(Keys("items").Index(0).Key("value"))
if !ok {
t.Fatal("missing explanation")
}
for _, change := range explanation.History {
if strings.Contains(change.Before.YAML, "EARLIER_PRIVATE") || strings.Contains(change.After.YAML, "EARLIER_PRIVATE") || strings.Contains(change.After.YAML, "HIDDEN_A") {
t.Fatalf("sensitive lineage leaked: %#v", change)
}
}
}

View File

@@ -0,0 +1,165 @@
package engine
import "go.maximumdirect.net/configkit/internal/document"
// collectionCorrespondence describes matching at one concrete registered path.
// A negative index denotes an identity present only at the other endpoint.
type collectionCorrespondence struct {
path Path
eligible bool
structural bool
entryToFinal []int
finalToEntry []int
}
type resolutionCorrespondence struct {
collections map[string]collectionCorrespondence
prefix pathPrefixIndex
indexedCollections []string
}
func (c resolutionCorrespondence) clone() resolutionCorrespondence {
var result resolutionCorrespondence
for _, collection := range c.collections {
collection.path = collection.path.clone()
collection.entryToFinal = append([]int(nil), collection.entryToFinal...)
collection.finalToEntry = append([]int(nil), collection.finalToEntry...)
result.indexCollection(collection)
}
return result
}
func (c *resolutionCorrespondence) indexCollection(collection collectionCorrespondence) {
if c.collections == nil {
c.collections = make(map[string]collectionCorrespondence)
}
pointer := collection.path.String()
c.collections[pointer] = collection
if collection.eligible {
c.prefix.insert(pointer, len(c.indexedCollections))
c.indexedCollections = append(c.indexedCollections, pointer)
}
}
func buildResolutionCorrespondence(entry, final *document.Node, policy sequencePolicy) resolutionCorrespondence {
var result resolutionCorrespondence
if policy.selectorTrie == nil {
return result
}
var indexes mappingIndex
var visit func(*document.Node, *document.Node, Path)
visit = func(before, after *document.Node, path Path) {
if fields, registered := policy.identityFieldsAt(path); registered &&
((before != nil && before.Kind == document.Sequence) || (after != nil && after.Kind == document.Sequence)) {
result.indexCollection(matchResolutionCollection(before, after, path, fields))
return
}
if before != nil && before.Kind == document.Mapping {
for _, item := range before.Mapping {
var next *document.Node
if after != nil && after.Kind == document.Mapping {
if position := indexes.find(after, item.Key); position >= 0 {
next = after.Mapping[position].Value
}
}
visit(item.Value, next, path.Key(item.Key))
}
}
if after != nil && after.Kind == document.Mapping {
for _, item := range after.Mapping {
if before == nil || before.Kind != document.Mapping || indexes.find(before, item.Key) < 0 {
visit(nil, item.Value, path.Key(item.Key))
}
}
}
}
visit(entry, final, Path{})
return result
}
func matchResolutionCollection(before, after *document.Node, path Path, fields []string) collectionCorrespondence {
result := collectionCorrespondence{path: path.clone()}
prior, priorIssues := validateNormalizedSequenceIdentityFrom(before, path, fields, sequenceIdentityDiagnosticCodes{}, 0, sequencePayloadRecord)
later, laterIssues := validateNormalizedSequenceIdentityFrom(after, path, fields, sequenceIdentityDiagnosticCodes{}, 0, sequencePayloadRecord)
if len(priorIssues) != 0 || len(laterIssues) != 0 {
return result
}
result.eligible = true
result.entryToFinal = make([]int, len(before.Sequence))
result.finalToEntry = make([]int, len(after.Sequence))
for i := range result.entryToFinal {
result.entryToFinal[i] = -1
}
for i := range result.finalToEntry {
result.finalToEntry[i] = -1
}
result.structural = len(prior) != len(later)
for tuple, previous := range prior {
if current, found := later[tuple]; found {
result.entryToFinal[previous.index] = current.index
result.finalToEntry[current.index] = previous.index
if previous.index != current.index {
result.structural = true
}
} else {
result.structural = true
}
}
return result
}
// entryPath translates a surviving final record prefix; additions have no entry.
// Paths outside eligible collections and wholly absent final positions stay positional.
func (c resolutionCorrespondence) entryPath(path Path) (Path, bool) {
return c.translate(path, false)
}
// finalPath translates historical record descendants without matching removals.
func (c resolutionCorrespondence) finalPath(path Path) (Path, bool) {
return c.translate(path, true)
}
func (c resolutionCorrespondence) translate(path Path, toFinal bool) (Path, bool) {
if !path.valid() {
return Path{}, false
}
translated := path.clone()
if len(c.indexedCollections) == 0 {
return translated, true
}
matched, handled := true, false
c.prefix.visitAncestors(path.String(), func(id int) {
if handled {
return
}
collection := c.collections[c.indexedCollections[id]]
depth := len(collection.path.segments)
if depth >= len(path.segments) {
return
}
for i, segment := range collection.path.segments {
if segment.kind == pathSegmentKey && path.segments[i].kind == pathSegmentIndex {
return
}
}
index, ok := schemaIndex(path.segments[depth])
indices := collection.finalToEntry
if toFinal {
indices = collection.entryToFinal
}
if !ok || index >= len(indices) {
return
}
handled = true
current := indices[index]
if current < 0 {
matched = false
return
}
translated.segments[depth] = pathSegment{kind: pathSegmentIndex, index: current}
})
if !matched {
return Path{}, false
}
return translated, true
}

View File

@@ -0,0 +1,73 @@
package engine
import "testing"
func TestResolutionCollectionCorrespondence(t *testing.T) {
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"},
}})
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
for _, test := range []struct {
name, before, after string
eligible, structural bool
finalIndex, entryIndex int
matched bool
}{
{"swap", "items: [{id: a}, {id: b}]", "items: [{id: b}, {id: a}]", true, true, 0, 1, true},
{"insert", "items: [{id: a}]", "items: [{id: b}, {id: a}]", true, true, 0, 0, false},
{"remove", "items: [{id: a}, {id: b}]", "items: [{id: b}]", true, true, 0, 1, true},
{"same", "items: [{id: a}]", "items: [{id: a}]", true, false, 0, 0, true},
{"duplicate", "items: [{id: a}, {id: a}]", "items: [{id: a}]", false, false, 0, 0, true},
{"missing", "items: [{name: a}]", "items: [{id: a}]", false, false, 0, 0, true},
{"identity replacement", "items: [{id: a}]", "items: [{id: b}]", true, true, 0, 0, false},
{"invalid final", "items: [{id: a}]", "items: [bad]", false, false, 0, 0, true},
} {
t.Run(test.name, func(t *testing.T) {
before := parseCompositionDocument(t, "before", test.before)
after := parseCompositionDocument(t, "after", test.after)
matching := buildResolutionCorrespondence(before, after, policy)
collection := matching.collections["/items"]
if collection.eligible != test.eligible || collection.structural != test.structural {
t.Fatalf("collection = %#v", collection)
}
entry, ok := matching.entryPath(Keys("items").Index(test.finalIndex).Key("value"))
if ok != test.matched || (ok && entry.String() != Keys("items").Index(test.entryIndex).Key("value").String()) {
t.Fatalf("entry = %s, matched = %t", entry.String(), ok)
}
})
}
}
func TestResolutionCorrespondenceDynamicCompoundIdentities(t *testing.T) {
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
selector: sequenceIdentitySelector{literalSequenceSelector("groups"), anyMappingSequenceSelector(), literalSequenceSelector("items")},
identityFields: []string{"id", "kind"},
}})
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
before := parseCompositionDocument(t, "before", "groups: {alpha: {items: [{id: 1, kind: x}, {id: '1', kind: x}]}, empty: {items: []}}")
after := parseCompositionDocument(t, "after", "groups: {alpha: {items: [{id: '1', kind: x}, {id: 1.0, kind: x}]}, empty: {items: []}}")
matching := buildResolutionCorrespondence(before, after, policy)
retained := matching.clone()
changed := matching.collections["/groups/alpha/items"]
changed.finalToEntry[0] = 0
matching.collections["/groups/alpha/items"] = changed
if entry, ok := retained.entryPath(Keys("groups", "alpha", "items").Index(0)); !ok || entry.String() != "/groups/alpha/items/1" {
t.Fatalf("retained correspondence shares mutable indexes: %s, %t", entry.String(), ok)
}
matching = retained
if empty := matching.collections["/groups/empty/items"]; !empty.eligible || empty.structural {
t.Fatalf("empty = %#v", empty)
}
path, ok := matching.entryPath(Keys("groups", "alpha", "items").Index(0).Key("missing"))
if !ok || path.String() != "/groups/alpha/items/1/missing" {
t.Fatalf("entry = %s, %t", path.String(), ok)
}
wrongShape, ok := matching.entryPath(Keys("groups").Index(0).Key("items").Index(0))
if !ok || wrongShape.String() != "/groups/0/items/0" {
t.Fatalf("mapping shape = %s, %t", wrongShape.String(), ok)
}
}

View File

@@ -0,0 +1,187 @@
package engine
import (
"strings"
"testing"
)
func TestMovedRemovedFieldHasConsistentHistory(t *testing.T) {
type config struct {
Items []map[string]any `yaml:"items"`
}
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
Resolve: func(value *config, r *Resolution) error {
delete(value.Items[0], "extra")
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(Keys("items"), "remove field and reorder")
return nil
},
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: a, extra: own}, {id: b}]\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
path := Keys("items").Index(1).Key("extra")
if _, found := result.Metadata().Lookup(path); found {
t.Fatal("removed field is effective")
}
explanation, found := result.Inspection.Explain(path)
if !found || len(explanation.History) != 2 {
t.Fatalf("explanation = %#v, %t", explanation, found)
}
history := result.Metadata().History(path)
if len(history) != 2 || history[0].Origin.Layer != LayerMain || history[1].Origin.Operation != ChangeNormalization {
t.Fatalf("metadata history = %#v", history)
}
last := explanation.History[1]
if last.Before.YAML != "own\n" || last.After.Present {
t.Fatalf("removal = %#v", last)
}
for _, unknown := range []Path{Keys("items", "1", "extra"), Keys("items").Index(1).Key("unknown")} {
if history := result.Metadata().History(unknown); len(history) != 0 {
t.Fatalf("unexpected history for %s: %#v", unknown.String(), history)
}
}
}
func TestMovedInheritedHistoryUsesFinalRecordPrefix(t *testing.T) {
type config struct {
Items []map[string]any `yaml:"items"`
}
field := Keys("items").Index(0).Key("nested").Key("more").Key("value")
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
Resolve: func(value *config, r *Resolution) error {
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
r.RecordNormalization(Keys("items"), "reorder")
r.RecordNormalization(field, "retain inherited field")
return nil
},
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items:\n - id: parent\n nested: {more: {value: old}}\n - id: child\n extends: {id: parent}\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
physical := result.Metadata().History(Keys("items"))
sourceID := result.Inspection.Sources()[0].ID
for _, query := range []Path{field, Keys("items").Index(0)} {
inherited, recorded := false, false
for _, change := range result.Metadata().History(query) {
if change.Origin.Operation == ChangeInheritance {
inherited = true
if !pathContains(Keys("items").Index(0), change.Path) {
t.Errorf("inheritance path for %s = %s", query.String(), change.Path.String())
}
if query.String() == field.String() && change.Path.String() != "/items/0" {
t.Errorf("container operation replaced by leaf path: %s", change.Path.String())
}
for _, location := range change.Origin.Locations {
if location.SourceID != sourceID || (query.String() == field.String() && location.Line != 3) {
t.Errorf("source coordinates changed: %#v", location)
}
}
}
if change.Origin.Layer == LayerResolution && len(change.Reasons) == 1 && change.Reasons[0] == "retain inherited field" {
recorded = true
if change.Path.String() != field.String() {
t.Errorf("final resolution path translated twice: %s", change.Path.String())
}
}
}
if !inherited || !recorded {
t.Errorf("missing inherited or recorded evidence for %s", query.String())
}
}
for _, change := range physical {
if change.Origin.Operation == ChangeInheritance && !pathContains(Keys("items").Index(1), change.Path) {
t.Errorf("collection history lost physical coordinates: %s", change.Path.String())
}
}
}
func TestCyclicMatchingProtectsRemovedAndDerivedValues(t *testing.T) {
type config struct {
Items []map[string]any `yaml:"items"`
Derived string `yaml:"derived"`
}
removed := Keys("items").Index(1).Key("extra")
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
Secret: func(path Path) bool { return path.String() == "/items/0/extra" },
Resolve: func(value *config, r *Resolution) error {
value.Derived = value.Items[0]["extra"].(string)
delete(value.Items[0], "extra")
last := value.Items[len(value.Items)-1]
copy(value.Items[1:], value.Items[:len(value.Items)-1])
value.Items[0] = last
r.RecordNormalization(Keys("items"), "remove field and rotate")
r.RecordDerivedDefault(Keys("derived"), "derive from removed value", removed)
return nil
},
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
result, err := testLoad(Bytes("records", []byte("items: [{id: a, extra: CYCLIC_PRIVATE_CANARY}, {id: b}, {id: c}, {id: d}]\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("items"), removed, Keys("derived")} {
explanation, found := result.Inspection.Explain(path)
if !found {
t.Fatalf("missing explanation for %s", path.String())
}
if strings.Contains(explanation.Effective.YAML, "CYCLIC_PRIVATE_CANARY") {
t.Fatalf("effective value leaked at %s", path.String())
}
for _, change := range explanation.History {
if strings.Contains(change.Before.YAML, "CYCLIC_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "CYCLIC_PRIVATE_CANARY") {
t.Fatalf("history leaked at %s", path.String())
}
}
}
var output strings.Builder
if err := result.Inspection.WriteEffective(&output); err != nil {
t.Fatal(err)
}
if strings.Contains(output.String(), "CYCLIC_PRIVATE_CANARY") {
t.Fatal("effective rendering leaked")
}
}
func TestCorrespondenceIndexPreservesPathShapes(t *testing.T) {
destination := Keys("groups", "0", "a/b~")
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
selector: sequenceIdentitySelector{
literalSequenceSelector("groups"), literalSequenceSelector("0"), literalSequenceSelector("a/b~"),
},
identityFields: []string{"id"},
}})
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
before := parseCompositionDocument(t, "before", "groups: {'0': {'a/b~': [{id: a}, {id: b}]}}")
after := parseCompositionDocument(t, "after", "groups: {'0': {'a/b~': [{id: b}, {id: a}, {id: new}]}}")
matching := buildResolutionCorrespondence(before, after, policy)
parsed, err := ParsePath("/groups/0/a~1b~0/0/field")
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{destination.Index(0).Key("field"), parsed} {
entry, found := matching.entryPath(path)
if !found || entry.String() != destination.Index(1).Key("field").String() {
t.Fatalf("entry = %s, %t", entry.String(), found)
}
final, found := matching.finalPath(entry)
if !found || final.String() != path.String() {
t.Fatalf("round trip = %s, %t", final.String(), found)
}
}
for _, path := range []Path{
Keys("groups").Index(0).Key("a/b~").Index(0),
destination.Key("0"), destination.Index(8), destination,
} {
entry, found := matching.entryPath(path)
if !found || entry.String() != path.String() {
t.Fatalf("positional path %s became %s, %t", path.String(), entry.String(), found)
}
}
if _, found := matching.entryPath(destination.Index(2)); found {
t.Fatal("new identity acquired entry evidence")
}
}

View File

@@ -80,7 +80,11 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa
classifyPath(resolved)
}
}
return closeSecretDependencies(paths, secret, dependencies, inherited)
var correspondence resolutionCorrespondence
if model := metadata.historyEvidence(); model != nil && model.resolutionPayload != nil {
correspondence = model.resolutionPayload.correspondence
}
return closeSecretDependenciesWithCorrespondence(paths, secret, dependencies, inherited, correspondence)
}
func addSecretPath(paths map[string]Path, pointer string) {

View File

@@ -6,6 +6,13 @@ type secretTrigger struct {
}
func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency) map[string]bool {
return closeSecretDependenciesWithCorrespondence(paths, sensitive, dependencies, inherited, resolutionCorrespondence{})
}
// Matching adds endpoint edges to the same work queue as recorded dependencies.
// Newly reached coordinates are indexed once, including historical fields that
// have no physical descendant at the corresponding final position.
func closeSecretDependenciesWithCorrespondence(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency, correspondence resolutionCorrespondence) map[string]bool {
result := make(map[string]bool, len(sensitive))
queue := make([]string, 0, len(sensitive))
for pointer, classified := range sensitive {
@@ -17,12 +24,14 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d
var triggers pathPrefixIndex
triggerValues := make([]secretTrigger, 0, len(dependencies)+2*len(inherited))
fired := make([]bool, 0, cap(triggerValues))
insert := func(dependency Path, target string, ordinary bool) {
if ordinary && !dependency.valid() {
return
}
id := len(triggerValues)
triggerValues = append(triggerValues, secretTrigger{target: target, ordinary: ordinary})
fired = append(fired, false)
triggers.insert(dependency.String(), id)
}
for _, dependency := range dependencies {
@@ -33,7 +42,46 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d
insert(link.target, link.dependency.String(), false)
}
fired := make([]bool, len(triggerValues))
var projected map[string]bool
if len(correspondence.indexedCollections) != 0 {
projected = make(map[string]bool, len(paths))
}
project := func(path Path) {
if projected == nil || !path.valid() {
return
}
pointer := path.String()
if projected[pointer] {
return
}
projected[pointer] = true
add := func(target Path, matched bool) {
if !matched {
return
}
targetPointer := target.String()
if targetPointer == pointer {
return
}
if _, exists := paths[targetPointer]; !exists {
paths[targetPointer] = target
}
insert(path, targetPointer, false)
}
add(correspondence.finalPath(path))
add(correspondence.entryPath(path))
}
if projected != nil {
// Projection may add missing target coordinates to paths. Snapshot the
// original candidates so map insertion cannot affect this traversal.
candidates := make([]Path, 0, len(paths))
for _, path := range paths {
candidates = append(candidates, path)
}
for _, path := range candidates {
project(path)
}
}
activate := func(id int) {
if fired[id] {
return
@@ -48,6 +96,9 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d
for head := 0; head < len(queue); head++ {
pointer := queue[head]
if classified, ok := paths[pointer]; ok && classified.valid() {
// Register a synthesized coordinate before visiting triggers, never
// while a trigger-index traversal is in progress.
project(classified)
triggers.visitAncestors(classified.String(), func(id int) {
if triggerValues[id].ordinary {
activate(id)

View File

@@ -140,7 +140,8 @@ type Override struct {
// Resolution lets a resolver inspect supplied values and record derived defaults
// or normalization. Its input queries describe the state at callback entry,
// even after the resolver changes the configuration. Use the context only during
// even after the resolver changes the configuration. Records and helpers use
// final paths; helpers do not follow moved records. Use the context only during
// the callback; do not retain it or use it concurrently.
type Resolution struct{ value *engine.Resolution }

View File

@@ -22,6 +22,8 @@ func (m Metadata) IsExplicit(path Path) bool {
// History returns detached definition, inheritance, and resolution records for path. A record's
// Path can be an ancestor when that container operation replaced or removed path.
// Removed historical paths can have history even though Lookup returns false.
// Surviving registered records follow their matched resolution-entry history;
// collection queries retain removed evidence at physical positions.
// Unknown and invalid paths return nil.
func (m Metadata) History(path Path) []Change {
values := m.value.History(toEnginePath(path))

View File

@@ -4,6 +4,10 @@
"expected_push_url": "ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git",
"validation": {
"forbidden_files": [".releasekit/gitea-token"],
"markdown": {},
"module_distribution": {
"local_replacements": true
},
"go": {
"race": true,
"full_vet": true,

View File

@@ -26,7 +26,8 @@ func SequenceAnyMappingKey() SequenceSelectorSegment {
// SequenceIdentity records the identity fields used for strict sequence
// composition, identified-record parent lookup, and final typed-result
// validation during loading.
// validation during loading. Eligible entry and final records also correspond
// during resolution auditing and inspection.
type SequenceIdentity struct {
exactPath bool
path Path