Skip to content

Rectify: Skill-Contract Immunity for Pre-Existing Artifacts (Forward Version-Skew) - #4474

Open
Trecek wants to merge 24 commits into
developfrom
impl-rectify_skill_contract_immunity-20260805-144018
Open

Rectify: Skill-Contract Immunity for Pre-Existing Artifacts (Forward Version-Skew)#4474
Trecek wants to merge 24 commits into
developfrom
impl-rectify_skill_contract_immunity-20260805-144018

Conversation

@Trecek

@Trecek Trecek commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

A stale, pre-contract-era copy of the bundled audit-bugs skill in an external user repo crashes every AutoSkillit entry point because invalid project-local skill candidates escape the resolution boundary and enforcement is smeared across at least twelve downstream consumers — eight of which hard-raise SkillContractError uncaught, and four of which silently consume the invalid content. This is the third manifestation of skill-contract changes shipping without a migration or tolerance path for pre-existing artifacts.

Closes #4470

Implementation Plan

Plan file: .autoskillit/temp/rectify/rectify_skill_contract_immunity_2026-08-05_133349.md

🤖 Generated with Claude Code via AutoSkillit

Trecek and others added 15 commits August 6, 2026 09:47
Replace SkillInfo.invalid_reason (a free-form accumulated string) with
SkillInfo.invalidities: tuple[SkillInvalidity, ...], where each entry
carries a typed SkillInvalidityKind plus its detail message.
invalid_reason becomes a derived property joining invalidities with
"; " — byte-identical to the old accumulated string, so every existing
string reader keeps working unchanged.

SkillInvalidityKind (core/types/_type_enums.py) enumerates every
current invalid_reason producer: FRONTMATTER_PARSE, FIELD_SHAPE,
RESERVED_FIELD, UNKNOWN_CAPABILITY, UNDECLARED_CAPABILITY,
SEMANTIC_UNDECLARED_TOKENS, SEMANTIC_MISSING_VERSION,
SEMANTIC_VERSION_MISMATCH, SEMANTIC_PLAN_INVALID. This makes the
contract surface mechanically enumerable — the precondition for the
forcing-function remediation registry in a later step.

parse_skill_semantic_plan now returns (kind, message) pairs instead of
bare strings so its diagnostics can be typed at the source.

Migrate every invalid_reason= construction site (frontmatter-parse
early return, __post_init__ self-derivation, the accumulated-reasons
build in _skill_info_from_frontmatter, its authenticity-diagnostics
replace() call, and _llm_triage.py's invalid_reason=None reset) to
build/reset invalidities instead. replace() with invalid_reason= would
now raise TypeError since it is a property, not a field.

SkillCatalogEntry keeps its own plain invalid_reason field untouched —
catalog entries are guaranteed valid by construction, so it needs no
migration.

Ref: GitHub issue #4470.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…2.2, 2.4)

Resolver fall-through (recipe-loader semantics):
- resolve_effective now iterates project-local candidates in precedence
  order and returns the first VALID one, logging and skipping invalid
  higher-precedence candidates instead of letting them shadow a valid
  bundled twin or a valid lower-precedence local copy. Falls through to
  the bundled source when no project-local candidate is valid; returns
  the highest-precedence invalid candidate only when nothing anywhere
  is valid, so callers can still report why a configured name failed.
- resolve_local_candidate preserves the OLD resolve_effective's
  first-path-match-regardless-of-validity semantics for _llm_triage,
  the one caller whose purpose is comparing raw, possibly-stale,
  on-disk content against a stored baseline.
- _list_effective_unfiltered now returns
  (tuple[SkillInfo, ...], tuple[SkillExclusion, ...]): a name is
  claimed by a project-local search dir only on successful parse (the
  recipe-loader "seen"-on-success rule), deleting the old clobber where
  an invalid shadowing copy replaced a valid bundled entry. Invalid
  candidates produce a SkillExclusion record instead of raising or
  silently vanishing.
- list_effective attaches exclusions to EffectiveSkillCatalog and
  narrows its remaining catalog-wide raise to bundled/extended-source
  entries only — invalid project-local candidates no longer reach it.
- validate_skill_tier_roles' invalid-tier message now includes the
  file path and per-kind remediation hints when the concrete resolver
  supplied a real SkillInfo.
- Fixed a latent bug in resolve_invocation's pack-expansion: it called
  _list_effective_unfiltered but iterated the old single-tuple return
  as if it still were one, which would have raised AttributeError for
  any skill with a PACK_REGISTRY-member activate_deps.

New IL-0 protocols (core/types/_type_protocols_workspace.py):
SkillInvalidityAuthority and SkillExclusionAuthority cross the IL-0
boundary the same way SkillFrontmatterAuthority already does;
EffectiveSkillCatalogAuthority gains an `exclusions` field. Threaded
through the two catalog-reconstruction sites that would otherwise
silently drop the records: compile_session_skill_catalog
(workspace/session_skills.py) and the projected-artifact authority's
namespace_sources rebuild (workspace/_projected_artifact/authority.py).

Remediation registry (core/types/_type_constants.py), the forcing
function (issue remediation item 2, second half): SKILL_CONTRACT_REMEDIATIONS
maps every SkillInvalidityKind to a SkillContractRemediationDef —
introduced_in, a DETERMINISTIC/ADVISORY RemediationAction, and a hint —
modeled on RETIRED_INSTALL_ARTIFACT_SHAPES, guarded by an
append-only-completeness assertion at import time. UNDECLARED_CAPABILITY,
SEMANTIC_MISSING_VERSION, and SEMANTIC_UNDECLARED_TOKENS are the three
kinds a future SkillMigrationAdapter can repair deterministically; the
rest are advisory-only. Documented the forcing function in AGENTS.md
§3.1 alongside the Hook/Skill-rename entries it's modeled on.

Ref: GitHub issue #4470.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrap the three composition roots' validate_skill_tier_roles/list_effective
calls in try/except SkillContractError instead of letting them crash with
a raw traceback (issue remediation item 3):

- cli/session/_session_cook.py (cook): catches around both the tier-role
  validation and the SESSION-role list_effective call (they're not
  adjacent in this function — onboarding/backend-resolution sits between
  them), prints a clean message plus a doctor pointer, exits non-zero.
- cli/session/_session_order.py (order): same treatment, adjacent block.
- server/_factory.py (make_context): logs a structured error event and
  re-raises as-is — every MCP-facing caller already wraps composition in
  try/except SkillContractError and renders its own envelope, so the
  message just needs to stay actionable (already true after 2.2).

On success, all three log/print one line per non-empty
catalog.exclusions (path + hints) via two new shared helpers in
cli/session/_session_launch.py: render_skill_contract_composition_failure
and render_skill_catalog_exclusions.

The five other crash sites named in the issue (_fleet_run.py,
skill_projection.py, _prompts.py, _serve_helpers.py,
tools_fleet_dispatch.py) need no guards — after 2.2 they can no longer
raise for project-local staleness, and genuine packaging errors should
still fail loudly there.

Updates the one pre-existing test whose pin this consciously changes:
test_cook_rejects_orchestrator_skill_in_l1_tier_before_launch moves from
pytest.raises(SkillContractError) to pytest.raises(SystemExit) plus an
output assertion (no Traceback, mentions the skill name and required role).

Ref: GitHub issue #4470.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
For a local-only invalid skill (the one case where an invalid SkillInfo
still deliberately escapes resolve_effective by design), these four sites
previously branched only on `is None` and silently consumed the invalid
candidate's content with no warning:

- recipe/_skill_helpers.py:_resolve_skill_md — now treats invalid as
  unresolved (returns None, the existing not-found branch) and logs the
  invalidity with remediation hints.
- recipe/_contracts_staleness.py:check_contract_staleness — now skips
  hashing an invalid candidate (treats it as absent, current_hash="")
  instead of hashing whatever invalid content is on disk, and logs.
- core/types/_type_helpers.py:resolve_target_skill — IL-0, so the guard
  uses only the invalid_reason field already visible on the Protocol
  (no new imports): an invalid candidate now renders as unresolved
  instead of resolving its (possibly wrong) source.
- cli/doctor/_doctor_config.py:standing_backend_pins_feasibility — now
  reports the invalidity as an explicit WARNING finding instead of
  silently skipping the semantic-plan feasibility check for that skill.

Extracted invalidity_hints() (workspace/skills.py) — the per-kind
remediation-hint lookup used by SkillExclusion.from_skill_info and
validate_skill_tier_roles (2.2) — into a shared helper so these new
call sites (and 2.6's doctor check) don't reimplement it.

Ref: GitHub issue #4470.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SkillMigrationAdapter (migration/engine.py), a DeterministicMigrationAdapter
registered in default_migration_engine():

- discover(project_dir): globs SKILL.md under all four
  ALL_PROJECT_LOCAL_SKILL_SEARCH_DIRS.
- needs_migration(file): resolves the raw candidate via
  resolve_local_candidate() (not resolve_effective — which would now fall
  through to a valid bundled twin instead of validating the stale file
  itself) and returns true iff at least one invalidity kind is registered
  DETERMINISTIC in SKILL_CONTRACT_REMEDIATIONS.
- migrate(file): applies every applicable deterministic remediation.
  UNDECLARED_CAPABILITY inserts the missing capability name(s) parsed from
  the authenticity diagnostic. SEMANTIC_MISSING_VERSION stamps
  semantic_version. SEMANTIC_UNDECLARED_TOKENS drops a retired capability
  name from uses_capabilities and stubs its semantic_requirements
  replacement field — the only half of that kind fixable without touching
  body text; a raw portable token (Agent(, subagent_type=, …) literally
  present in prose cannot be repaired frontmatter-only, so that half stays
  advisory in practice despite the registered action, with the hint still
  pointing at the fix. Frontmatter is re-serialized as a whole; body is
  carried through byte-for-byte from the parsed result.
- Raises on an unrecognized deterministic kind (the guard T8 exercises).

Exported RETIRED_SEMANTIC_CAPABILITIES (was module-private in
skill_capabilities.py) so the adapter doesn't duplicate the
retired-capability-to-semantic-field mapping.

CLI driver (cli/app.py): the `migrate` command was hardcoded to recipes
and never touched MigrationEngine, so a registered skill adapter stayed
unreachable. Added a skill pass (_migrate_skills): reports every pending
skill's path, invalidity kinds, and hints unconditionally; a new `--fix`
flag (default off, preserving the pinned read-only contract in
tests/cli/test_install.py) applies migrate() per file via the engine
and prints per-file fixed/FAILED results. `--check`'s exit-code gate
stays scoped to recipes, unchanged. Documented in docs/cli.md.

Ref: GitHub issue #4470.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Added DefaultSkillResolver.scan_effective() — a public pair-returning
wrapper around _list_effective_unfiltered, giving doctor (and other
operator-facing tooling) an entry point that doesn't reach for the
underscore-prefixed internal implementation.

_check_project_local_skill_contracts (cli/doctor/_doctor_skills.py,
registered as check 41): reports every SkillExclusion from
scan_effective() — path, invalidity kinds, remediation hints, and an
`autoskillit migrate --fix` pointer when at least one kind is
DETERMINISTIC. Report-only, consistent with doctor's no-writes contract
(test_doctor_readonly.py). This is the check that makes the "silently
dropped user-native skills" erosion visible for project-local skills:
resolution-boundary containment already prevents the crash/clobber, but
an excluded file's only trace before this was a log line nobody reads.

The analogous warn-and-drop of invalid ~/.codex/skills profile entries
(session_skills.py, THIRD_PARTY source, outside DefaultSkillResolver) is
an explicit non-goal here — it's a user-global profile directory, not a
project artifact, and keeps its existing behavior.

Ref: GitHub issue #4470.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test-first coverage for the resolution-boundary containment, remediation
registry, migration adapter, doctor check, and silent-degrade hardening
implemented in the preceding commits (2.1-2.7). Written after the
implementation given the "run no test runner" constraint on this skill —
each test's correctness is reasoned against the concrete new/changed
behavior it targets, not verified red-then-green locally; the orchestrator's
test-check gate is authoritative.

T1-T4 (tests/workspace/test_skills.py): the #4470 composition reproduction
(stale project-local audit-bugs falls through to its bundled twin, one
exclusion recorded); a local-only invalid skill excluded with a record;
multi-dir fall-through preserves recipe-loader semantics; replaces the
fail-closed pin with a fall-back-with-exclusion-record pin, extends the
local-only pin to assert the exclusion.

T5 (tests/cli/test_cook_profile.py): a tier-configured skill invalid
everywhere reports its path, a remediation hint, and an
`autoskillit doctor` pointer — clean SystemExit, no traceback.

T6 (tests/server/test_factory.py): the real make_context() composition
path survives a stale shadowing skill and logs the exclusion.

T7-T9 (new tests/contracts/test_skill_contract_remediations.py +
tests/contracts/fixtures/skill_contract_corpus/): bundled/repo-local
skill hygiene pinned at merge time; every SkillInvalidityKind has a
registered remediation (with a teeth-test proving the DETERMINISTIC-
coverage guard actually rejects an unhandled kind); a three-fixture
historical corpus that must validate cleanly or migrate cleanly.

T10 (tests/migration/test_engine.py, tests/cli/test_install.py):
SkillMigrationAdapter discover/needs_migration/migrate behavior, plus
the CLI reachability tests — default `migrate` reports without touching
the file, `migrate --fix` rewrites it.

T11 (tests/cli/test_doctor.py): the new project-local skill contracts
doctor check reports both a shadowing and a local-only invalid skill
with kinds/hints/fix-pointer, and passes clean on a clean project.

T12 (tests/recipe/test_rules_skill_content.py, test_contracts.py,
tests/cli/test_doctor_standing_pins.py,
tests/contracts/test_target_skill_invocability.py): all four hardened
silent-degrade sites now treat a local-only invalid candidate as
unresolved/absent instead of silently consuming it.

T13 (tests/workspace/test_session_skills_allow_only_and_closure.py):
pack expansion survives an unrelated invalid project-local skill in the
same _list_effective_unfiltered scan — pins the skills.py pack-expansion
unpacking fix across the 2.2 signature change.

T14 (tests/test_llm_triage.py): triage still receives the raw, stale
project-local content via resolve_local_candidate for a skill shadowing
a valid bundled twin, not the bundled twin resolve_effective's
fall-through would otherwise silently substitute.

Ref: GitHub issue #4470.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hment

- SkillMigrationAdapter.migrate(): the SEMANTIC_UNDECLARED_TOKENS branch now
  detects the case where no declared retired capability was found (the only
  trigger was a raw literal token in the body, which this frontmatter-only
  adapter never edits) and returns success=False instead of silently
  claiming a fix that never happened.
- workspace/skills.py: the three project_local_skill_rejected warning call
  sites (resolve_effective, resolve_local_candidate,
  _list_effective_unfiltered) now carry hints=invalidity_hints(...), per
  plan step 2.4's wiring list.
- validate_skill_tier_roles: the upgraded error message now also names the
  bare invalidity kind(s), alongside path/hints/doctor pointer.
- cli/app.py _migrate_skills(): --fix now prints per-file results — which
  invalidity kinds a successful deterministic fix addressed, plus hints for
  any ADVISORY kinds left unaddressed on the same file.

Addresses items 1-4 of the consolidated audit-impl remediation task list.
- missing_semantic_version.md: add the missing logical_roles declaration for
  'researcher' so the fixture's only defect is the intended missing
  semantic_version (it previously carried an unrelated second
  SEMANTIC_PLAN_INVALID defect the migration adapter isn't meant to fix).
- test_engine.py: test_migrate_no_op_when_nothing_deterministic had an
  orphaned assertion (asserting MIGRATE_RECIPES_MAX_RETRIES, an LLM-recipe-
  migration constant unrelated to this deterministic, non-retrying adapter)
  glued onto its end by a bad insertion splice. Removed it, and restored it
  to its actual owner — test_failed_headless_retries_match_constant, whose
  name promises exactly this assertion and was the one left without it.

Addresses items 5-6 of the consolidated audit-impl remediation task list.
…d skills

Three of the four Step 2.7 silent-degrade guard tests shadowed a real
bundled skill (resolve-failures, investigate, open-kitchen). Post-2.2,
resolve_effective correctly falls through to the valid bundled twin for a
shadowed name, so the invalid_reason is not None guard at each site never
fired — one test failed outright, one was vacuous (verified by temporarily
reverting the guard clause and confirming it still passed), and one only
coincidentally matched.

Replaced all three with a fabricated, local-only skill name that has no
bundled twin anywhere — the only scenario in which resolve_effective still
returns an invalid SkillInfo post-2.2, and the actual scope Step 2.7's own
framing describes. Each replacement is empirically verified to discriminate
guard-present from guard-absent behavior by temporarily reverting the
guard and confirming the test fails:

- test_doctor_standing_pins.py: renamed to
  test_invalid_local_only_skill_reports_invalidity_instead_of_silent_skip;
  authors a minimal project-local recipe whose one step targets the
  fabricated skill name, reached via agent_backend.recipe_overrides.
- test_contracts.py: current_value expectation corrected to '' (the actual,
  verified behavior — the hash-mismatch branch never fires for an
  empty/absent current_hash, so a local-only invalid override yields an
  empty stale list rather than a StaleItem).
- test_target_skill_invocability.py: input skill_command chosen so the
  guard-present and guard-absent renders visibly diverge (autoskillit:
  sigil preserved vs. stripped to the PROJECT_LOCAL namespace).

Addresses items 7-9 of the consolidated audit-impl remediation task list.
…r change

- test_project_local_overrides.py:
  test_prepare_skill_projection_authenticates_project_root_not_managed_add_dir
  pinned the old fail-closed behavior (pytest.raises(SkillContractError,
  match='effective skill catalog contains invalid contracts')) for a second,
  independent crash site (skill_projection.py:239 via prepare_skill_
  projection) — one of the plan's five 'no guard needed' sites, which now
  falls through to the valid bundled process-issues twin instead of
  raising. Updated to assert the projection succeeds using bundled content,
  with an exclusion recorded, mirroring how T4 handled the analogous case
  in test_skills.py.
- test_type_protocol_shards.py: test_workspace_shard_all pins the exact
  protocol-name set exported from _type_protocols_workspace.py. Added the
  two new IL-0 protocols this implementation correctly introduced —
  SkillExclusionAuthority and SkillInvalidityAuthority — both structurally
  necessary per plan step 2.2's IL-0 boundary-crossing paragraph.

Addresses items 10-11 of the consolidated audit-impl remediation task list.
…fication sweep

test_order_rejects_orchestrator_skill_in_l1_tier_before_launch pinned a raw
SkillContractError propagating out of cli.order(...), but Step 2.3's
composition-root rendering change (_session_order.py) already catches
SkillContractError around validate_skill_tier_roles and exits cleanly —
exactly the same change T5 already accounted for on the cook path
(test_cook_rejects_orchestrator_skill_in_l1_tier_before_launch), just never
mirrored for order's own pin. Confirmed via git stash that this failure
predates every change in this remediation pass (reproduces identically on
the unmodified branch). Updated to the same pytest.raises(SystemExit) +
output-assertion pattern as the cook pin.

Found via the broader tests/cli/, tests/workspace/, tests/recipe/,
tests/contracts/, tests/migration/, tests/core/, tests/server/ sweep run to
verify the 11 listed remediation items — outside the original 11-item list
but a real, confirmed regression on this branch that task test-all would
otherwise catch.
@Trecek
Trecek force-pushed the impl-rectify_skill_contract_immunity-20260805-144018 branch from 01a841a to 46f54e8 Compare August 6, 2026 16:55
@Trecek
Trecek enabled auto-merge August 6, 2026 22:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant