config: shadow-value check, sources/version presence tracking, additive install --generate - #550
Open
NikashPrakash wants to merge 7 commits into
Open
NikashPrakash wants to merge 7 commits into
NikashPrakash wants to merge 7 commits into
Conversation
Extends the manifest-corruption fix (#535) to the two remaining fields that encoding/json could not omit. sources is the material one. LoadAgentsRC synthesizes the default local source into the struct so every consumer can assume a usable entry, and with no presence tracking that synthesized value serialized back on the next save — writing a declaration the author never made. sources merges as an ordered REPLACE, so an injected local-only list wholly replaces the source set an org layer supplies, the same way the injected `false` beat an org layer's `true`. Reachable from any manifest write: skills promote, agents import/new, install --generate. A slice cannot carry presence in its own value the way a pointer can: omitempty treats nil and [] alike, and the whole point of the load-time default is that Sources is never empty by the time anything can save it. So presence is tracked out-of-band on an unexported field that defaults to "declared" — the zero value serializes as before, so struct literals in other packages, a plain Unmarshal, and GenerateAgentsRC are unaffected, and only LoadAgentsRC's own synthesis flips it. Suppression is additionally value-guarded, so a caller that edits Sources after load still serializes its edit. version re-emitted as 0 only affects already-invalid files, but 0 is outside the schema's version enum, so it gets plain omitempty: zero can only mean absent, which encodes presence losslessly without a pointer.
Repos hit by the injection bug — or by hand-editing — are left carrying
explicit repo-local values that the layer stack would supply anyway.
Nothing in the resolved output distinguishes a deliberate override from
a leftover: the repo-local key simply wins, silently, forever.
`da config verify` gains a shadow check over every CategoryScalar
repo-local key, comparing it against what the stack resolves to with
the repo-local layer removed:
- REDUNDANT (warn): the values match. Harmless today, but it pins the
repo against a future org-layer change — the failure mode where an
org flips a default and one repo mysteriously does not follow.
- OVERRIDE (pass): the values differ. Legitimate, so it does not
warn, but both values and the losing layer are named so an operator
auditing an org rollout can see which repos flipped what.
Nothing here can fail the report. verify was chosen over doctor
because it is the structured, JSON-capable, per-check surface; doctor
prints untyped prose.
Candidate keys come from the resolver's own category table via a new
exported FieldMergeCategory, so the check cannot drift from the merge
semantics it reasons about. Only scalars qualify: set-union, map-merge
and ordered-replace keys combine rather than shadow. version and
$schema are exempt as manifest structure (version is schema-required,
so restating it is mandatory, not redundant), as are the repo-local-only
protected fields.
MergeGenerateAgentsRC was the last route that silently replaced a deliberate declaration. A successful scan overwrote an explicit hooks/mcp/settings value, so regenerating could re-enable a projection the repo had deliberately turned off — the same class of silent override the manifest-corruption fix removed everywhere else. Since those fields became pointers, "the author wrote hooks:false" is finally distinguishable from "the key is absent", so the scan now fills an ABSENT declaration only. --force-generate opts back into replacing. The base also flips from generated to existing. Under the old base every field the generator does not produce was dropped on each --generate unless someone had added a preservation clause, and clauses were in fact added one incident at a time (project, repo_id, ExtraFields, observability, stage_profiles, manifests) — each after a field went missing in the field. Destructive-by-default with a hand-maintained exception list is not a contract anyone can hold; every new typed field silently joined the casualty list. Basing on existing makes extends, kg, features, packages, execution_profile and every future field safe without anyone having to notice. It also fixes work_tracking, which the generator sets on any git repo and which had no preservation clause, so a committed backend choice was reset by every --generate. What the scan still replaces is unchanged and deliberate: skills, agents and rules are scan-derived sets with no other convergence path — nothing else prunes a manifest entry for a resource deleted under ~/.agents/, so replacing them is the documented cleanup for a stale or over-captured manifest and stays load-bearing in both modes.
Resolve commands/config/verify.go help text: keep both the prompt-units check bullet from master and the shadow check bullet from this branch, and retain the warning sentence covering redundant repo-local shadows. Check registration keeps master's order with the shadow check appended after the precondition-policy checks.
A path-less `{"type":"local"}` source names the user's own ~/.agents
resource home, which install already links unconditionally as the canonical
store. Declaring it as a project source made install resolve that home a
second time and let a user-scope resource masquerade as a project-declared
one, and `da install --generate` committed it into shared manifests.
Add Source.IsDefaultHomeLocal and AgentsRC.ProjectOwnedSources as the
ownership discriminator, resolve only project-owned sources in install,
drop the sentinel from the generated manifest, and narrow the
MergeGenerateAgentsRC source union to both sides' project-owned roots.
Authored roots -- git/http/oci, and path-bearing local layers -- are
unchanged.
Homebrew deprecated cask URL verification and GoReleaser stopped writing it into the generated Cask as of v2.18.1, so `goreleaser check` now exits non-zero on it and fails the Linux test job. The `url` block held nothing else, so the key and its anchor go away entirely.
`git commit` spawns a detached `git maintenance run --auto --quiet --detach` that keeps creating and unlinking transient paths under .git (objects/maintenance.lock, multi-pack-index, bitmap-ref-tips_*) after the commit has already returned. That races copyGitTemplate's WalkDir over the shared template repo — an enumerated entry vanishes before it can be lstat'd, surfacing as "copy git template into ...: lstat .../.git/objects/maintenance.lock: no such file or directory" — and a fixture's own t.TempDir teardown, which rmdir's .git while maintenance repopulates it. Set maintenance.auto=false and gc.auto=0 before the first commit in each fixture's git-config sequence. Copies inherit the template's local config, so setting it on the template covers every repo cloned from it. Verified with GIT_TRACE: the default config spawns the maintenance child, this one spawns none.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Follow-up batch to #535. Four items, all in the same hazard class the manifest-corruption fix opened up: a value that is present in a repo manifest wins the layer merge whether or not anyone meant to write it.
1. Shadow-value check — surfaced through
da config verifyRepos that were hit by the injection bug (or hand-edited) are left carrying explicit repo-local values identical to what the layer stack would supply. Nothing in the resolved output distinguishes a deliberate override from a leftover: the repo-local key simply wins, silently, forever.
Surface chosen:
da config verify, notda doctor. Both exist.doctoris a fixed sequence ofreport*functions printing untyped prose to stdout with no JSON mode;config verifyalready has the exact shape this needs — a[]VerifyCheck{Name, Status, Detail}report, a stable--jsoncontract, a per-checkpass|warn|failstatus, and an established "oneverify_<check>.go+verify_<check>_test.goper check" convention. It is also the command whose remit is offline manifest contract checks, which is precisely what this is.For each repo-local scalar key the stack below also supplies:
warnpassNothing here can fail the report.
Candidate keys come from the resolver's own category table through a new exported
FieldMergeCategory, so the check cannot drift from the merge semantics it reasons about. OnlyCategoryScalarkeys qualify — set-union, map-merge and ordered-replace keys combine rather than shadow, andsourcesis repo-local by design.versionand$schemaare exempt as manifest structure (versionis schema-required, so restating it is mandatory, not redundant), as are the repo-local-only protected fields (repo_id,project)."What the stack would supply" is computed by re-asking
Snapshot.FieldAtagainst the layer slice with the repo-local layer removed — reusing the resolver's own merge rather than reimplementing it. Only repo-local is dropped: the.agentsrc.local.jsonoverlay is uncommitted but still outranks every imported layer, so it genuinely is what would take effect.E2E against a copy of this repo's own manifest (
"settings": falsebeside anextends; the live manifest is untouched), with the org layer pointed at a local fixture:2.
sourcespresence-tracking hazardsourceshad noomitempty, andLoadAgentsRCsynthesizes a default local source when the key is absent — so that synthesized value serialized back on the next save, writing a declaration the author never made.sourcesmerges as an ordered replace, so an injected local-only list wholly replaces the source set an org layer supplies, exactly the way the injectedfalsebeat an org layer'strue. Reachable from every manifest write:skills promote,agents import/new,install --generate.Presence-tracking mechanism. A slice cannot carry presence in its own value the way a pointer can:
omitemptytreatsniland[]alike, and the whole point of the load-time default is thatSourcesis never empty by the time anything can save it — so the pointer/nil-slice discipline that fixedhooks/mcp/settingsdoes not transfer directly. Presence is therefore tracked out-of-band on an unexportedsourcesSynthesizedfield, deliberately oriented so its zero value means "declared":false(the default) ⇒ serializeSourcesnormally. EveryAgentsRCbuilt anywhere else — struct literals in other packages, a plainjson.Unmarshal,GenerateAgentsRC— behaves exactly as before. There is no invisible-flag hazard where a manifest built outside this package silently loses its sources.LoadAgentsRC's own synthesis sets it totrue, andMarshalJSONthen nils the mirror field soomitemptydrops the key.Sourcesstill holds exactly what was synthesized. A caller that appends a git source (agents import) or replaces the list has made a real declaration, and it serializes despite the flag. No real edit can be swallowed by a stale flag.Full field lifecycle applied per
schema-usage.md: struct,agentsRCCoremirror,MarshalJSON, andschemas/agentsrc.schema.json(sourcesdocumented as optional-and-kept-absent; it was already not inrequired, andagentsRCKnownalready listed it).Verified through a real
da skills promoteon a fixture manifest that omits both keys — the save adds the skill and leavessourcesandversionabsent.3.
Versionre-emitting 0Only affects already-invalid files, but re-emitting
"version": 0writes a value the schema'senum: [1,2]rejects. Plainomitemptyon the int rather than a pointer: 0 is outside the enum, so zero can only mean absent — presence is encoded losslessly without a pointer.4.
MergeGenerateAgentsRCoverwrite contractDecision: changed, but scoped — and the load-bearing half of the old contract is preserved. The blanket "generated values fill absent fields only" would have broken a real flow, so the change splits along the line the pointer types made visible:
"hooks": falsesilently re-enables a projection the repo deliberately turned off. Since config: fix silent manifest corruption — refresh rewrote .agentsrc.json and disabled org-layer hooks/mcp/settings #535 made them pointers, "the author wrote false" is finally distinguishable from "the key is absent", so the scan now fills an absent declaration only.--force-generateopts back into replacing.~/.agents/;da refreshdeliberately refuses to touch the manifest. Replacing them is the documented cleanup for a stale or over-captured manifest, pinned by an explicit regression test (TestInstallGenerateOverStaleManifestDropsGlobalOverDeclarations, whose comment states "with no separate prune step needed"). Making these fill-absent-only would silently delete that cleanup path, so they still replace in both modes.The base also flips from
generatedtoexisting, which is the larger structural fix. Underout := *generated, every field the generator does not produce was dropped on each--generateunless someone had added a bespoke preservation clause — and clauses were in fact added one incident at a time (project,repo_id,ExtraFields,observability,stage_profiles,manifests), each after a field went missing in the field. Destructive-by-default with a hand-maintained exception list is not a contract anyone can hold: every new typed field silently joined the casualty list. Basing onexistingmakesextends,kg,features,packages,execution_profile,pr_source,precondition_policies,locksand every future field safe without anyone having to notice.That also fixes a latent bug found on the way:
work_trackingis set by the generator on any git repo and had no preservation clause, so a committed non-default backend was reset by every--generate— despite the generator's own comment claiming "existing manifests are untouched".Absent scalars still fill from the scan (
project,repo_id,$schema,version,work_tracking), so the v1-manifest bootstrap keeps working, andrepo_idstays protected per org-config-resolution §7.4.Contract docs updated where documented: the
installcobraLong+Example,da explain's flag list, anddocs/COMMANDS.md(both theinstallandconfig verifyrows).Verification
New-file coverage —
commands/config/verify_shadow.gois at 100% on every function (the 100% ratchet for new files):Tests are table-driven and same-package, with thin
t.Runclosures — seeding and assertion bodies are extracted into named helpers (mergeOverStaleManifest,assertStaleScanSetsPruned,assertSOBNames,shadowSnapshot,assertCheckNames/Statuses/Details,roundTripWithMutation) to stay under the S3776 cognitive-complexity gate.