fix(skills): the legacy-adoption refusal files one row per refused URL, not one per sync (#2744) - #2777
Merged
Conversation
#2744) Rule #1: the requirements delta lands before the code. §21.1.3 described adoption as "idempotent and fail-soft" and said nothing about the refusal's ALERTING. `_adopt_legacy_clone` runs as the first statement of every `sync_library()`, so on an install that is past migration but still carries a non-matching `skills_library_url` the terminal refusal files a fresh `priority: "high"`, `expires_at: None` operator-queue item on every sync — unattended under the ent#236 auto-sync loop (300s floor ⇒ 288 rows/day), never expiring, and un-dismissable because each row carries a new timestamped `request_id`. The rule this states: that refusal is the designed resting state of a migrated install, not a failure, so it is `low` + `logger.info` with a stable URL-derived id whose family prefix is reserved; the two genuine failure branches keep `high` and their repeat-visible ids by product decision. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…cho (#2744) TDD — RED on this commit, green on the next. Proven red for the right reason, not merely red: test_n_syncs_..._exactly_one_item 5 distinct timestamped ids test_a_different_refused_url_... frozen clock ⇒ both URLs share one id test_the_terminal_refusal_is_not_high... priority "high", logger.error test_the_stable_id_is_reserved_... 'skills-legacy-adoption-<ts>' unreserved test_a_pat_bearing_url_is_never_echoed... the PAT is in context.url AND the log test_the_actionable_branches_keep_high... GREEN on base, by design (AC 4) The last one is the anti-regression half: it pins behaviour the fix must NOT change, and goes red only if the low/stable-id treatment is applied to all three call sites instead of the one the issue names. Harness notes, both load-bearing. `_record_adoption_failure` imports `utc_now_iso` INSIDE its body, so the patch target is `utils.helpers` — patching `services.skill_service.utc_now_iso` binds nothing and yields a vacuous test. And `validate_skills_library_url` does a live `socket.getaddrinfo`: on a sandboxed resolver a terminal-branch test would silently drive the validation-reject branch and fail as "5 distinct ids" / "priority is high", reading exactly like the fix regressing — so DNS is stubbed to the `gaierror` that function already tolerates, and every terminal-branch test additionally pins which branch produced its item. New file rather than an append to test_ent346_skills_source_injection.py: Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…ne per sync (#2744) `_adopt_legacy_clone()` is the first statement of every `sync_library()`. On an install that is past migration but still carries a `skills_library_url` matching no configured source, the terminal "already has sources" branch called `_record_adoption_failure`, which minted a TIMESTAMPED `request_id` at `priority: "high"` with `expires_at: None`. One permanent, high-priority, operator-unclearable row per sync, forever — 17 of them (~17% of everything pending) on the reporting install, and 288/day at the ent#236 auto-sync floor. Two behaviour changes, on ONE branch: * a STABLE, URL-keyed id (`skills-legacy-adoption-refused-{sha256(url)[:12]}`) so `create_item`'s `(agent_name, request_id)` ON CONFLICT DO NOTHING collapses N syncs to exactly one row — and, since that conflict target ignores `status`, an operator's dismissal finally sticks; * `priority: "low"` + `logger.info`, because this is the designed resting state of a migrated install, not a failure. Shaped as a keyword-only `steady_state` flag on the existing emitter rather than a second method: one #1677 `_ALLOWED_CALLERS` key, and a `False` default that leaves the two actionable call sites LITERALLY UNCHANGED lines — the strongest available proof of AC 4. The emitter keeps its name despite now serving a non-failure; renaming costs the allowlist key and churns a file two people are editing this week. The hash is over the RAW `url.strip()`, and is computed INSIDE the try: a non-str setting value must degrade to a warning and no alarm, not turn a decorative alarm into a raiser. Normalising the input instead would re-enter `validate_skills_library_url`, which does a live `socket.getaddrinfo` and can raise — a network call and a raise path inside a fail-soft alarm. Two things the stable id makes mandatory, both included: * `skills-legacy-adoption-` joins `_RESERVED_ID_PREFIXES`. An id derived from an admin-visible URL is guessable, so an agent could pre-create it and silence the alarm through the sink's ON CONFLICT (the #1632 C2 class); and `is_platform_minted` reads the same tuple to gate the ent#499 responded write-back and the ent#329 respond→resume dispatch, which this change makes an expected operator action. The FAMILY prefix, so all three call sites and the 17 historical rows classify correctly. * the URL echo is `strip_url_credentials`-scrubbed. The emitter's docstring claimed the credential case was handled, and that was true of `message` and of nothing else: `EmbeddedCredentialError` is a `ValueError` subclass, so the validation-reject branch is exactly the one a PAT-bearing URL reaches, and the raw value landed at ERROR in the Vector-captured log and durably in `operator_queue.context` — SQLite, every backup, rendered in the Operating Room (Invariant #12, Rule #5). The hash still keys on the raw value; scrubbing first would collide two different tokens on one repo. The #1677 justification is corrected in the same commit: "admin-driven sync cadence" is false (ent#236's loop is unattended), and the real bound — the only input is a setting blocked on the generic settings PUT — is co-located as a comment at the emitter, where it is likelier to stay true. Unchanged and deliberately so: the `count_skill_sources() > 0` guard itself, both validators, the grant branch, `expires_at: None`, the `title`/`question` copy, and `context["alert_type"]`. No schema change — `request_id` and its unique index shipped in #1631 — so Invariant #9 is not triggered: no `db/migrations.py` entry and no Alembic revision. Clearing the lingering `skills_library_url` key stays out of scope pending a separate investigation. Tests: tests/unit/test_2744_skills_adoption_alert_idempotency.py Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
… bound (#2744) The reservation of `skills-legacy-adoption-` is enumerated in three live places and all three now carry it: `requirements/security.md` §26.7's reserved-id guard, and `operating-room.md`'s two enumerations (the ingestion-guard list and the #1632 ingestion-caps paragraph). `operating-room.md`'s "Platform exemption & emitter budget (#1677)" bundled the skills alarm into a disjunction that includes "operator-driven". That was the same false claim the `_ALLOWED_CALLERS` justification made — ent#236's auto-sync drives `sync_library()` unattended on a 300s-86400s timer, so nothing admin- or operator-driven bounds it. The paragraph now names this emitter's actual bound, per branch: the terminal refusal is idempotent by a URL-keyed id (≤1 row per refused URL) and what makes it platform-only is that its only input is a setting blocked on the generic settings PUT. Plus the two dated rows (`operating-room.md` Revision History, the `feature-flows.md` change log) and the Operating Room catalog row. All three enumerations were ALREADY stale — each omits prefixes the live tuple carries. Ours is added; their pre-existing drift is deliberately not swept here (Rule #2) and is named as a follow-up instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…item for the PAT (#2744) Two gaps the review found in the new file, both in tests only. `context["reason"] = "already_migrated"` is the discriminator the emitter grew because one `alert_type` and one title now span both `low` (the benign resting state) and `high` (a URL that failed validation — the signature of an attempted injection). Nothing asserted it, so the field the fix added to be read by a machine could be dropped by a later edit in silence. The credential test asserted the PAT is absent from `context["url"]` and from the captured log, but not from `question`. `question` is credential-free only because neither ent#346 validator echoes the URL in its `ValueError` (`validate_skills_library_url` names the hostname or the resolved IP; `reject_embedded_credentials` names neither) — the scrub does not reach it. A validator message that starts echoing the URL would reopen the leak durably in `operator_queue.question` with no guard. Sweeping the serialized item covers every field the emitter writes, not the two that were remembered. Both were verified to bite: dropping the `reason` key fails the first, and reverting `strip_url_credentials` to the raw url fails the second. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…#2744) `QueueItemDetail.vue` renders every `context` key, so the discriminator the emitter grew would have surfaced to operators as a `reason | already_migrated` row. Put to the product owner as keep / drop / rename; the answer was drop — nothing reads the key today, so removing it is non-breaking. The steady-state branch is now discriminated by `priority: low` plus the `logger.info` level alone. The assertion added in 69274de7 to pin the key goes with it; it lived inside an existing test, so no test is removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
vybe
marked this pull request as ready for review
September 14, 2026 13:16
vybe
pushed a commit
that referenced
this pull request
Sep 14, 2026
…2764) — mechanical, per the merge-train note on the PR #2777 passes steady_state=True to the same call; without **kw the stub raises TypeError on the merged tree and the ent#346 negative control fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtVjZEsxjyz2E4x5XdQg99
This was referenced Sep 14, 2026
vybe
pushed a commit
that referenced
this pull request
Sep 14, 2026
…2763) (#2764) * fix(skills): match the legacy library URL by repo, not by raw string (#2763) `_adopt_legacy_clone` decided whether a legacy `skills_library_url` named an already-configured source with raw string equality, while the rest of the platform stores that URL normalized. The same repository written two ways therefore never matched, the install took the ent#346 "already has sources" refusal branch on every sync, and filed a fresh un-deduped high-priority alert each time (#2744 is that flood). `validate_skills_library_url` is a validator AND a normalizer (`github.com/o/r` -> `https://github.com/o/r`), and `routers/skills.py:649` uses it as one when it stores a source. This path called it and discarded the return. Both representations occur on real installs by construction: the bundled default source is seeded from `config.TRINITY_DEFAULT_SKILL_SOURCE`, a bare literal that never passes through the validator, while a source created via `POST /api/skills/sources` is stored normalized. So either direction of the mismatch is reachable, and both are covered. Two changes: assign the validator's return, and compare through the new `_same_skills_repo`, which normalizes the STORED side too — normalizing only the setting would still miss every install whose source came from the seed. `reject_embedded_credentials` keeps seeing the ORIGINAL string; it must judge what was actually written, not a form we produced. This cannot weaken ent#346. A match returns an existing source id and creates no row, so it is the no-op branch; the grant branch (`count_skill_sources() == 0` -> `create_skill_source`) is untouched, and a key naming a genuinely different repo still reaches the refusal. Normalizing removes false positives from the detector without widening what may be granted — asserted by two of the eight tests, not just claimed here. Scope is deliberately the scheme/no-scheme split the platform itself creates. `…/repo.git`, a trailing `/` and the bare `owner/repo` shorthand stay distinct and are pinned by a test, so collapsing them later is an argued ent#346 decision rather than a quiet widening. Verified: reverting ONLY the call site (keeping the helper defined) fails exactly the two regression cases and leaves the other six green — the fix flips what it targets and nothing else. 496 passed across every skills-related suite. Fixes #2763 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ * merge-train: widen the _record_adoption_failure stub to accept **kw (#2764) — mechanical, per the merge-train note on the PR #2777 passes steady_state=True to the same call; without **kw the stub raises TypeError on the merged tree and the ent#346 negative control fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtVjZEsxjyz2E4x5XdQg99 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: sim <sim@example.com>
vybe
approved these changes
Sep 14, 2026
vybe
left a comment
Contributor
There was a problem hiding this comment.
merge-train: batch validated on train/20260914-1332
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.
Fixes #2744
Problem
SkillService._adopt_legacy_clone()is the first statement of everysync_library(). On an install that is past migration but still carries askills_library_urlsystem setting matching no configured source, it takes the terminalcount_skill_sources() > 0branch and calls_record_adoption_failure(), which minted a timestampedrequest_idatpriority: "high"withexpires_at: None.A timestamped id defeats
create_item's(agent_name, request_id)ON CONFLICT DO NOTHING by construction, andexpires_at: Nonemeans the 5-secondmark_operator_queue_expiredsweep never reaps the row. So: one permanent, high-priority, operator-unclearable alert per sync, forever — 17 of them (~17% of everything pending) on the reporting install, and 288/day at the ent#236 auto-sync interval floor.The guard itself is correct. ent#346's refusal, the
validate_skills_library_url+reject_embedded_credentialspair, and the grant branch are all untouched here. Only the alert cadence and severity of one already-correct refusal change.The change
Two behaviour changes, on one branch only — the terminal "this install already has skills sources, so it is past migration" branch, which is the designed resting state of every migrated install whose legacy key lingers, not a failure.
Fix 1 — a stable, URL-keyed
request_id.f"skills-legacy-adoption-refused-{hashlib.sha256(url.strip().encode()).hexdigest()[:12]}"create_item's ON CONFLICT on(agent_name, request_id)then collapses N syncs to exactly one row per refused URL. A different refused URL still mints its own id and raises its own item (AC 3)..strip()because the setting is operator-authored free text and a trailing newline would otherwise mint a second permanent row for the same URL. The hash is taken over the raw URL, never a normalised one: normalising means re-callingvalidate_skills_library_url, which performs a livesocket.getaddrinfoand can raise — a network call and a raise path inside a decorative, fail-soft alarm whose whole contract is that it never blocks a sync. The id computation stays inside thetry, so a non-strsetting value degrades to alogger.warningand no alarm rather than turning the alarm into a raiser.This is also strictly better under concurrency:
_adopt_legacy_clone()runs outside theSingleFlightLockacquired later insync_library(), so two uvicorn workers and the scheduler can enter it at once. Today that mints N different timestamped ids ⇒ N rows; with the stable id, concurrent inserts collide and one row survives however many raced.Fix 2 —
priority: "low"+logger.infoon that branch only.Shaped as a keyword-only
steady_state: bool = Falseon the existing_record_adoption_failurerather than a second emitter method — one#1677_ALLOWED_CALLERSkey (the qualname is unchanged), and it mirrors the in-file precedent_announce_reconcile_refusal, which already uses a stable content-derived id on the same_skills-synchost.AC 4, provable by diff. The default
Falsereproduces today's behaviour exactly, so the two actionable call sites (validation reject, adoption exception) are literally unchanged lines. Grepping_record_adoption_failureacross thesrc/backend/diff returns only thedefpair:An unchanged call site cannot regress. Both keep
priority: "high"and their timestamped, repeat-visible ids.skills-legacy-adoption-joins_RESERVED_ID_PREFIXES. The family prefix, deliberately — it covers the new-refused-stable id, the two actionable branches' timestamped ids, and the 17 historical rows. The reasoning, in order:_skills-syncis uncreatable at agent CREATE (sanitize_agent_namestrips a leading_) but not on the rename path:routers/agent_rename.pyuses a bespoke sanitizer, commented "same as agent creation" when it is not —_is in its keep class and.strip('-')strips hyphens only, so_skills-syncmaps to itself. An owner (no admin needed) can therefore land a real agent on that name and have_poll_cycleingest its file under the matchingagent_name.sub-headroom-/_sub-headroom,db-backup-/_db-backupandlog-archive-/_log-archiveare all reserved despite their hosts being equally uncreatable. The two unreserved skills siblings are the outliers, not the rule.is_platform_minted, which has three sinks, not two: the ent#499 responded write-back, and the ent#329 respond→resume gate inrouters/operator_queue.py. Unreserved, this item answers "not platform-minted", which is simply wrong for an item the platform mints — and this PR makes "an operator clicks through on it" the expected action.Verified safe: the prefix check runs only in
_sync_agentingestion, never indb.create_item, so the platform's own create is unaffected.Credential scrub on the URL echo (
strip_url_credentials, already imported in this file and already used for this purpose). This is a deliberate, disclosed scope addition — it is 2 lines inside the hunk this PR already rewrites, and the alternative was to carry forward a docstring claim known to be false. The emitter's docstring asserted that a credential-bearing URL is refused by a message that does not echo it. That is true ofmessageand of nothing else:EmbeddedCredentialErroris aValueErrorsubclass, so the validation-reject branch is exactly the branch a PAT-bearing URL reaches, and it passed the rawurlto bothlogger.error(...)andcontext["url"]. Askills_library_urlof the formhttps://ghp_…@github.com/o/rtherefore landed the PAT (i) in the Vector-captured backend log at ERROR and (ii) durably inoperator_queue.contextJSON in SQLite → every backup → rendered in the Operating Room. CWE-312, Rule #5, Invariant #12, the same class as ent#334/ent#347. The hash still runs over the raw value — scrubbing first would collide two different tokens on one repo.Decisions recorded
skills_library_urlkey — is deferred, as the issue author asked. The issue marks it a deliberate behaviour change with a security-review caveat: on the reporting install the key's value provably differs from the one existing source's URL and was written after migration completed, and why is under separate private investigation — that answer decides whether clearing is hygiene or destroying the only record of a refused write. Fixes (1) and (2) are independent of it. The diff contains no read, write or clear of that key;skills_library_urlappears in the source diff only inside added docstring prose.context["reason"] = "already_migrated"was dropped by the owner at review. An earlier commit added it as a machine-readable discriminator between the two classes now sharing onealert_type.QueueItemDetail.vuerenders everycontextkey, so it would have surfaced to operators as areason │ already_migratedrow; put to the owner as keep / drop / rename, the answer was drop. Nothing reads the key, so removal is non-breaking. The steady-state discriminator ispriority+ thelogger.infolevel alone.#1677justification string was false and is corrected._ALLOWED_CALLERSread "platform-only: admin-driven sync cadence (ent#346)"; ent#236's auto-sync loop calls the samesync_library()unattended on a 300s–86400s timer, so nothing admin-driven bounds it. The emitter stays platform-only — correctly — but on the real ground: its only input isskills_library_url, whichrouters/settings.pyblocks on the generic settings PUT (LEGACY_SKILLS_LIBRARY_KEYS, 422) and no other writer reaches, so no agent can drive its volume. Corrected intests/unit/test_1677_operator_alert_emitters.py::_ALLOWED_CALLERS, co-located as a comment at the emitter (the guard compares(path, qualname)set membership and never asserts the justification text, which is precisely how this string drifted into falsehood), and infeature-flows/operating-room.md.Behaviour to know
request_idand itsUNIQUE(agent_name, request_id)index shipped in bug: operator_queue.id is a global primary key — cross-agent id collision silently swallows requests #1631/fix(operator-queue): scope request-id uniqueness per agent (#1631) #1684. Nodb/migrations.pyentry and no Alembic revision; nothing to go looking for.create_item's conflict target is(agent_name, request_id)and ignoresstatus, so once the single stable-id row exists a later sync's insert is a no-op whatever that row's status is. That satisfies AC 1 ("at most one pending item") more than strictly, and the issue names "unclearable by the operator" as part of the defect. But the UI's Acknowledge button callsrespondToItem(id, 'acknowledged', '')→db.respond_to_item, which writes statusresponded— andrespondedis deliberately excluded from Clear All (db/operator_queue.py:492admits onlyacknowledged/cancelled/expired) because "their response still has to be delivered to the agent". Nothing will ever deliver this one:mark_acknowledgedfires only from a real agent's file sync, and_skills-synchas no container. The item would leave the open pane and sit un-hideable in the resolved list.POST /api/operator-queue/{id}/cancel(orbulk-cancel) yieldscancelled, which is terminal, Clear-All-able, and uses the normal retention window rather thanmax(OPERATOR_QUEUE_RESPONDED_MIN_RETENTION_DAYS=30, operator_queue_retention_days)._sweep_operator_queue_retentionreturns early whendays <= 0, andoperator_queue_retention_days = 0is a documented "disabled" value (_guard_allowscan also refuse the prune). On such an install, because the conflict target ignoresstatus, dismissing this alert mutes the refusal permanently — including a later genuine refusal at the same URL. The residual signal is the per-synclogger.info, which is not suppressible and is Vector-captured. Both halves stated rather than a bound asserted that does not always exist.create_itemnever updates on conflict, socreated_at,questionandcontext.urlstay pinned to the first refusal; there is no "last seen" anywhere in the queue. With fix (3) deferred, this replaces a recurring timestamped operator-visible signal with one frozen row plus a log line. That is the tradeoff, named.highitems keep their timestamped ids and stay pending until dealt with — a data-cleanup migration overoperator_queueis out of scope and is the destructive direction (bug: retention floor (#1065) silently deletes pre-existing execution history on upgrade — currently dev-only, ships to everyone at next release #1638's lesson). The zero-code remedy isPOST /api/operator-queue/bulk-cancel→cancelled→ Clear All hides them → the Add operator_queue retention sweep to cleanup_service #1142 sweep deletes them. After this fix, at most one new item is filed per refused URL.lowdoes not weaken the ent#346 security posture. The control is the refusal — the row is not created, the repo is not adopted, no skills are injected. Unchanged. The alert is observability about a refusal that already succeeded; it still exists, still names the refused URL, and still logs under the greppable[ent#346]prefix (at INFO, which the root logger captures). The id is URL-keyed, so a changedskills_library_urlmints a new item — a new URL still alerts.Coordination with #2763 / PR #2764
Complementary, and both wanted — dolho's comment on #2744 says so: "#2764 deliberately takes none of the three — it only stops the branch being entered spuriously. All yours." #2744 is the blast-radius fix (any future legitimate refusal is bounded); #2763 removes the false positive firing today (a raw-vs-normalised URL compare that enters this branch for a repo the install is already syncing).
PR #2764 is still open and not on
dev, so nothing is reconciled here — this note stands in for it.In
skill_service.pythe hunks are adjacent but non-overlapping: theirs are a new module-level_same_skills_repo(), thevalidate_skills_library_urlreturn assignment, and theexistingcomparison; ours areimport hashlib, thesteady_state=Truekeyword at the terminal call site, and the emitter body ~1500 lines further down. A rebase either way is a pure line-offset rebase.The one genuine collision is in #2764's test file, not in the source. Its new test stubs our emitter with a positional-only lambda:
After this change the terminal branch calls
self._record_adoption_failure(url, msg, steady_state=True)⇒TypeError: <lambda>() got an unexpected keyword argument 'steady_state'. That TypeError is raised inside_adopt_legacy_clone'stry:, so the outerexcept Exceptionswallows it and re-calls the emitter with two positional args — which the lambda does accept. The failure is therefore not clean:len(alerts) == 1still holds, but the message becomes"legacy skills-library adoption failed: <lambda>() got an unexpected keyword argument…", soassert "already has" in alerts[0][1]fails pointing at the wrong cause. Whoever lands second widens the stub tolambda self, url, msg, **kw.Operational note. Once #2764 lands, the reporting install may stop entering this branch at all. Do not wait for the single
lowitem to appear as confirmation — absence is the expected outcome there.Tests
tests/unit/test_2744_skills_adoption_alert_idempotency.py— a new file rather than an append totest_ent346_skills_source_injection.py, so the two parallel branches do not collide in one test module. Every test drives the real_adopt_legacy_clone; the only stubs aredbandlibrary_root. Two harness rules are load-bearing:utc_now_isois imported inside the emitter body, so the patch target isutils.helpers.utc_now_iso(patchingservices.skill_service.utc_now_isoyields a vacuous test), and every terminal-branch test also asserts"already has" in item["question"]—validate_skills_library_urldoes a livesocket.getaddrinfo, so on a sandboxed resolver a test intending the terminal branch would silently drive the validation-reject branch and fail reading exactly like the fix regressing.Red-on-base (service change stashed), reproduced independently by a second reviewer — 5 failed / 2 passed of the 7 node ids:
test_n_syncs_in_the_terminal_state_file_exactly_one_itemtest_a_different_refused_url_gets_its_own_itemsha256(url.strip())[:12]id per URL, frozen clocktest_the_terminal_refusal_is_not_high_prioritylow, andlogger.infonotlogger.errortest_the_stable_id_is_reserved_and_id_shaped_RESERVED_ID_PREFIXES, 12 lowercase hex,^[A-Za-z0-9._:-]+$, ≤512test_a_pat_bearing_url_is_never_echoed_into_the_log_or_the_queuecontext["url"]test_the_actionable_branches_keep_high_and_repeat_visible_ids[validation reject…]test_the_actionable_branches_keep_high_and_repeat_visible_ids[adoption exception…]After the fix: 7 passed (6 test functions; the AC-4 test is parametrized over both actionable branches). The owner's
context["reason"]drop removed one assertion, not a test.Deliberately not asserted here: that the DB actually dedupes. That is
create_item's contract, shipped and covered by #1631/#1684. These tests assert the property the service controls — the id is stable — and cite the mechanism./verify-local --skip-agenton the pre-rebase tip (projecttrinity-verify-83674660): unit 15,554 passed / 31 skipped / 0 failed (710 s), integration 70 passed / 13 skipped / 2 deselected / 0 failed, build + import-smoke and boot + health both pass./reviewand/cso --diffboth CLEAN on the diff, with the security pass recording the change as net-positive because it closes the pre-existing CWE-312 exposure above.Docs touched
docs/memory/requirements/skills.md§21.1.3low, stable URL-derived id, reserved prefix, dismissal sticks, scrubbed echo; the two failure branches stayhighby product decision, not because they are boundeddocs/memory/requirements/security.md§26.7skills-legacy-adoption-added to the reserved-prefix enumerationdocs/memory/feature-flows/operating-room.mddocs/memory/feature-flows.mdtests/registry.jsonNo architecture delta: neither
architecture.mdnorarchitecture/agent-lifecycle.mddocuments the legacy-adoption path.docs/memory/feature-flows/skills-library-sync.mdis already stale w.r.t. ent#237/ent#346; rewriting it is a separate docs issue, not this one.Follow-ups (named, not filed)
routers/agent_rename.py's bespoke sanitizer keeps a leading_and strips hyphens only, so all five_-prefixed alarm hosts —_skills-sync,_retention-guard,_db-backup,_log-archive,_sub-headroom— are landable by an owner. Route rename through the samesanitize_agent_nameused at creation._skills-syncis missing fromcanary/snapshot.py::_PLATFORM_ALARM_SENTINELS, so every pending_skills-syncalarm reports as an L-03 ghost-agent orphan. This fix improves the volume (17 → 1) but converts a decaying stream into one permanent finding._announce_fleet_failures(skills-fleet-reinject-{ts},medium,expires_at: None, one permanent row per partially-failed sweep, and its "one per sync run" justification is equally false) and_announce_reconcile_refusal(re-floods when the orphan count flaps). Neither is reserved.skills_library_urlre-enters the validation branch every sync, forever, athigh. AC 4 forbids changing that here.#1677justifications machine-checkable — structured (edge_triggered | idempotent_id | cooldown | leader_locked | operator_driven) plus an AST check that an emitter claimingidempotent_idhas no timestamp interpolation in its id expression. Two entries currently assert bounds that do not hold; this turns "found after 17 rows in production" into "caught at commit".expires_atin addition to the stable id — self-clears the item from the open pane and dissolves the "respondedrow nothing will ever acknowledge" wart above.expires_atinstead of the stable id would be strictly worse (a row per sync, each flipped toexpired).strip_url_credentialsis userinfo-only. It scrubsuser:pass@host; a token in the path or query (…/repo?token=…) still reaches the log andcontext. The reported shape is covered; the general one is not.questionis credential-free only by an undeclared coupling — it happens to be built from the exception text on branch A, andEmbeddedCredentialErrorhappens not to echo the URL. Nothing pins that; a future message that interpolates the URL would reopen the leak on a field this PR does not scrub..claude/agents/test-runner.mdcatalog row for the new test file. Edited but uncommitted — the.claudesubmodule is detached in this worktree.🤖 Generated with Claude Code
https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv