MCP tools by agent kind¶
Twenty-one tools. The pre-redesign surface was 74, and a clarify pod could see all of them and legally call four.
Tools not in the pod's profile are not registered at all. tools/list is per-profile, so an agent cannot see a tool it may not call. (This deliberately breaks the byte-identical-tools/list prompt-cache optimisation. That is an accepted cost.)
resolveProfile fails closed. An empty or unknown TATARA_TOOL_PROFILE serves only the always-on set and logs WARN - and it does not register submit_outcome, which means the pod has no terminal tool and the failure is loud rather than silent.
TATARA_TOOL_PROFILE is the agent kind (Task.status.agentKind), one of seven: brainstorm, incident, implement, review, refine, documentation, upgrade.
clarify folded into implement (contract 4)
The #521 lifecycle redesign deleted clarify as a kind, platform-wide. Its three decisions - implement / close / discuss - became action values (approved / rejected / discuss) on the implement outcome, so the pod that judges the approval grammar is the same pod that goes on to write the code. implement absorbed clarify's issue_write grant and its memory-recall tools; there is deliberately no "clarify" key anywhere in the profile or schema maps any more - resolveProfile fails closed, so a pod still reporting that profile gets no tools and no submit_outcome at all, rather than a live path around the gate. See Approval Gates.
The five tool groups¶
| Group | Count | Tools |
|---|---|---|
| Platform | 7 | task_get, task_list, task_context, task_note, project_get, repo_list, report_internal_issue |
| SCM | 4 | scm_read, issue_write, mr_write, mr_takeover_request |
| Code graph | 4 | code_search, code_context, code_graph, code_explain |
| Memory | 5 | memory_query, memory_describe, memory_write, memory_entity, memory_edges |
| Outcome | 1 | submit_outcome (one name, seven profiles, six distinct schemas - upgrade reuses documentation's) |
The profile gating table¶
Always-on for every profile, including the fail-closed empty one: task_get, task_context, task_note, project_get, repo_list, report_internal_issue.
| tool | brainstorm | incident | implement | review | refine | documentation | upgrade |
|---|---|---|---|---|---|---|---|
submit_outcome | yes | yes | yes | yes | yes | yes | yes |
task_get / task_context / task_note | yes | yes | yes | yes | yes | yes | yes |
project_get / repo_list / report_internal_issue | yes | yes | yes | yes | yes | yes | yes |
task_list | yes | yes | - | - | yes | - | - |
scm_read | yes | yes | yes | yes | yes | yes | yes |
issue_write | - | - | yes | - | yes | - | - |
mr_write | - | - | yes | yes | comment-only* | yes | yes |
mr_takeover_request | - | - | yes | yes | - | - | - |
code_search | yes | yes | yes | yes | - | yes | yes |
code_context | yes | yes | yes | yes | - | yes | yes |
code_graph | yes | yes | yes | yes | - | yes | yes |
code_explain | yes | yes | yes | yes | - | yes | yes |
memory_query / memory_describe | yes | yes | yes | yes | yes | yes | yes |
memory_write | yes | yes | yes | - | - | yes | yes |
memory_entity | yes | yes | - | - | - | yes | - |
memory_edges | - | yes | - | - | - | yes | - |
* refine's mr_write is restricted to action=comment by a cli-side and operator-side check, not by the schema. It is the only non-uniform cell in the table.
implement absorbed the deleted clarify profile's grants: the merged agent conducts the conversation on the issue and writes the code, so it needs issue_write and clarify's memory recall tools. It deliberately does not gain task_list (denied to implement, same as before) and does not gain memory_entity or memory_edges.
upgrade gets implement's code and memory-write grants plus mr_write - it opens MRs across however many repos one dependency-upgrade unit spans - but not issue_write (a cron mint has no issue and drives no approval gate) and, like every MR-opening kind, not task_list or mr_takeover_request. Sibling-unit dedup goes through task_context(index=true), which is always-on, instead.
Counts are derived from the table above, not quoted from a summary line: each profile is 21 minus the cells the table marks - for it (the always-on row and submit_outcome are never denied, so they never subtract).
| profile | denied cells | count |
|---|---|---|
| brainstorm | issue_write, mr_write, mr_takeover_request, memory_edges | 17 |
| incident | issue_write, mr_write, mr_takeover_request | 18 |
| implement | task_list, memory_entity, memory_edges | 18 |
| review | task_list, issue_write, memory_write, memory_entity, memory_edges | 16 |
| refine | code_search, code_context, code_graph, code_explain, memory_write, memory_entity, memory_edges, mr_takeover_request | 13 |
| documentation | task_list, issue_write, mr_takeover_request | 18 |
| upgrade | issue_write, mr_takeover_request, task_list, memory_entity, memory_edges | 16 |
Counts: brainstorm 17, incident 18, implement 18, review 16, refine 13, documentation 18, upgrade 16. implement moved from 17 to 18 when it absorbed issue_write from the deleted clarify profile - its only cell that changed.
task_list goes to the broad-context trio only, because an implement / review pod that can list every Task can wander into another Task's work. issue_write is implement plus refine; brainstorm and incident file issues through submit_outcome, so the proposal cap and dedup still apply. code_* is denied to refine - a backlog groomer reads issues, not code. Graph-mutating memory tools are denied to conversational and reviewing pods. And the one non-uniform cell, honestly: refine's mr_write is restricted to action=comment by a cli-side and operator-side check, not by the schema.
The three tools whose absences are the point¶
mr_write has three actions: open, comment, reply¶
No merge. No approve. No request_changes.
The platform has one bot identity, so an implement pod holding mr_write(approve) can approve its own MR. And two writers of a review - the agent via mr_write, the operator via submit_outcome - forced a head-SHA reconciliation gate that was itself a wedge. So: the operator posts every SCM review, from the accepted review submit_outcome. One writer per medium. The agent writes conversation; the operator writes reviews, merges, labels and status. A hallucinated merge call has nowhere to land.
action=open is idempotent: an existing open MR on the same TASK_BRANCH head branch returns {"status":"ok","existing":true,...} without calling the forge. It is refused with 409 when the Task already owns a merged MR for that repo - the structural stop on the duplicate-PR path after a partial merge.
It is also refused with 409 {"reason":"approval-required",...} on an implement Task while any live Issue the Task owns carries no approval evidence. That is what makes the approval gate load-bearing rather than advisory: there is no merge request that can carry unapproved work, so code written before the gate grants is lost. The body names each blocking issue with a detail (needs-maintainer-comment, needs-approval-tool, over-auto-approve-ceiling) and a guidance sentence, and tatara-cli renders it as a normal tool result rather than a tool error - the remedy is reachable in the same session. The idempotent answer above is a read of an MR that already exists and survives the gate; a Task owning zero live Issues (takeover, adopted upgrade) is ungated.
issue_write has no status and no labels parameter¶
Approval and every lifecycle label are operator-owned. A labels key would let an agent stamp the trigger label and self-escalate.
scm_read(kind=ci) is the only live forge read¶
kind=issues, kind=mr and kind=comments are served from the CR mirror and never touch the forge. repo is required on every kind.
kind=ci is the gh run watch / gh pr checks replacement, and it is a polled point read, not a blocking watch - because turnTimeoutSeconds is an inactivity window, so a single blocking call longer than it terminally fails the turn. The operator paces it server-side to one forge fetch per 20 seconds per (repo, number); a call inside the window is served from the last result with "cached": true. logTail (last 4000 bytes) is served only for a check whose conclusion is failure, timed_out or cancelled - a green run's logs are never fetched.
status is not a fold over checks[]. On GitLab it is the head pipeline's own aggregate, which the forge already computes across child pipelines, retries and allow_failure; checks[] is drill-down detail, and mergeable means no-conflicts AND not-red. Folding the job list instead is what shipped green on a failed pipeline until tatara-operator#609: GitLab's /pipelines/{id}/jobs omits the trigger: bridge jobs that carry a child pipeline's result.
Two kinds of row consequently appear in checks[] with no logTail even when they failed, because neither has a log of its own - a trigger: bridge, whose url is the DOWNSTREAM pipeline where the failing job lives, and an external commit status, whose url is the reporter's page. status stays authoritative for both. On GitLab, none likewise means no CI observation at all: an MR with no pipeline but an external commit-status reporter answers that reporter's verdict, not none. On GitHub, none can still appear with a legacy commit-status reporter present, since GitHub.PRChecks reads only /commits/{sha}/check-runs.
submit_outcome¶
One name, seven profiles, six distinct schemas. implement's action enum grew the three approval-gate actions when clarify's schema was deleted rather than merged; upgrade (added 2026-08-13) reuses documentation's schema object verbatim rather than duplicating it - both are gate-less code kinds whose action enum is submitted/declined, and a hand-maintained copy would only invite the two to drift on a field nobody meant to change independently. REST: POST /tasks/{task}/outcome, body {"kind":"<profile>","payload":{...}}.
kind must equal Task.status.agentKind (409 on mismatch - the pod's claim is not trusted). The call is idempotent for the same (task, agentKind, state), and it 409s when the Task is in a terminal state.
Five actions. submitted and declined are the pre-redesign pair; approved, discuss and rejected are the three folded-in clarify decisions, gated behind the extended approval grammar.
{"type":"object","properties":{
"task":{"type":"string"},
"action":{"type":"string","enum":["submitted","declined","approved","discuss","rejected"]},
"title":{"type":"string","description":"MR title. Required when action=submitted."},
"body":{"type":"string","description":"MR body. Required when action=submitted."},
"change_significance":{"type":"string","enum":["major","minor","patch"],
"description":"Required when action=submitted. major=backward-incompatible; minor=backward-compatible feature; patch=fix. YOU own this level - a reviewer may raise it but can never lower it."},
"merge_order":{"type":"array","items":{"type":"string"},
"description":"REQUIRED when this task's MRs span more than one repo: the Repository CR names in dependency order, first-merged first. There is NO default. Get it wrong and a downstream repo ships against an API that has not merged yet."},
"decline_reason":{"type":"string","description":"Required when action=declined."},
"reason":{"type":"string","description":"ALWAYS required for action=approved, action=discuss and action=rejected. For approved, say in plain words WHAT you are treating as the go-ahead: name the maintainer and why you read their comment as approval, or - when tatara proposed this issue itself and no human has commented - say exactly that."},
"approving_maintainer":{"type":"string","description":"Required for action=approved WHENEVER you are citing a human comment as the go-ahead: the login of the maintainer whose comment you are citing. Send it together with approval_citations - both, or neither, never one alone. OMIT BOTH only when tatara proposed this issue itself and no human has commented on it. It is a DECLARATION, not an authority - the operator refuses if it is not a verified maintainer, and refuses again if it does not match the author of the comment cited."},
"plan_note_id":{"type":"string","description":"ALWAYS required for action=approved, including when no human commented: the id returned by the task_note(kind=\"plan\") call that wrote the plan being approved. The operator hashes that note's body at grant and re-checks the hash before you write code, so a plan swapped after approval is refused."},
"approval_citations":{"type":"array","items":{"type":"object","properties":{
"id":{"type":"string"},"quote":{"type":"string"}},
"required":["id","quote"]},
"description":"Required for action=approved whenever a maintainer has commented, sent together with approving_maintainer: ONE entry per issue this task owns. id is the comment's external_id, copied verbatim from your turn-0 bundle. quote is a VERBATIM substring of that comment's body. If a LATER maintainer comment withdraws the approval you would otherwise cite, send action=discuss instead."}},
"required":["action"],"additionalProperties":false}
action=approved does not by itself advance the Task - the operator independently re-verifies the citation via restapi.verifyApprovalScope and refuses (parking at identity-unverified, HTTP 200) if it does not hold up. This is the same grammar clarify used to run, now driven from inside the same pod that goes on to write the code.
Its own schema, split out of implement's during the clarify fold: a documentation agent has no approval gate to drive, so it never gets the two gate-only actions (approved, rejected).
discuss added (tatara-cli#104, tatara-operator#628)
Before this, the only non-delivery exit was declined, which parks implement-declined - UnparkNever, no re-entry. Every technical blocker a documentation batch hit (not a real "nothing to document") had nowhere to go but that permanent park. discuss parks awaiting-human instead, which the next non-bot comment releases - provided the Task owns an open MR, so open it before calling discuss, or there is nothing for a human to comment on.
{"type":"object","properties":{
"task":{"type":"string"},
"action":{"type":"string","enum":["submitted","declined","discuss"]},
"title":{"type":"string","description":"MR title. Required when action=submitted."},
"body":{"type":"string","description":"MR body. Required when action=submitted."},
"change_significance":{"type":"string","enum":["major","minor","patch"],
"description":"Required when action=submitted. major=backward-incompatible; minor=backward-compatible feature; patch=fix. YOU own this level - a reviewer may raise it but can never lower it."},
"merge_order":{"type":"array","items":{"type":"string"},
"description":"REQUIRED when this task's MRs span more than one repo: the Repository CR names in dependency order, first-merged first. There is NO default. Get it wrong and a downstream repo ships against an API that has not merged yet."},
"decline_reason":{"type":"string","description":"Required when action=declined."},
"reason":{"type":"string","description":"Required when action=discuss: why you are pausing instead of finishing this turn with submitted or declined."}},
"required":["action"],"additionalProperties":false}
discuss refuses every MR-shaped field (title, body, change_significance, merge_order, decline_reason) via the same refuseCodeArgs check implement's code actions share - sending one alongside discuss is a 400, not a silent ignore.
Reuses documentation's schema object byte-for-byte - same fields, same submitted/declined/discuss action enum, same merge_order requirement across a multi-repo hop. merge_order here is the dependency-publish order (e.g. containers before charts before helmfile), not an arbitrary list: getting it backwards ships a chart against an image tag that never published. There is no approval gate to drive - an upgrade Task has no source issue - so it never gets implement's two gate-only actions.
{"type":"object","properties":{
"task":{"type":"string"},
"action":{"type":"string","enum":["submitted","declined","discuss"]},
"title":{"type":"string","description":"MR title. Required when action=submitted."},
"body":{"type":"string","description":"MR body. Required when action=submitted."},
"change_significance":{"type":"string","enum":["major","minor","patch"],
"description":"Required when action=submitted. major=backward-incompatible; minor=backward-compatible feature; patch=fix. YOU own this level - a reviewer may raise it but can never lower it."},
"merge_order":{"type":"array","items":{"type":"string"},
"description":"REQUIRED when this task's MRs span more than one repo: the Repository CR names in dependency order, first-merged first. There is NO default. Get it wrong and a downstream repo ships against an API that has not merged yet."},
"decline_reason":{"type":"string","description":"Required when action=declined."},
"reason":{"type":"string","description":"Required when action=discuss: why you are pausing instead of finishing this turn with submitted or declined."}},
"required":["action"],"additionalProperties":false}
declined is a correct and common answer here, not an edge case: most scheduled runs will find every candidate already claimed by a sibling Task, nothing eligible under the project's minimumReleaseAge, or a hop that turns out to be unsafe. See Upgrade.
{"type":"object","properties":{
"task":{"type":"string"},
"verdict":{"type":"string","enum":["approve","request_changes"]},
"change_significance":{"type":"string","enum":["major","minor","patch"],
"description":"Optional. It may only RAISE the level the implementer declared, never lower it."},
"reviewed_shas":{"type":"array","minItems":1,"items":{"type":"object","properties":{
"repo":{"type":"string"},"number":{"type":"integer"},"sha":{"type":"string"}},
"required":["repo","number","sha"]},
"description":"REQUIRED. The head SHA you ACTUALLY CHECKED OUT AND READ, per MR. The operator re-reads the live head and REFUSES your verdict if it moved while you were reviewing - anything pushed after your checkout would otherwise merge unreviewed under your approval."},
"findings":{"type":"array","items":{"type":"object","properties":{
"repo":{"type":"string"},"number":{"type":"integer"},
"path":{"type":"string"},"line":{"type":"integer"},"body":{"type":"string"},
"severity":{"type":"string","enum":["critical","high","medium","low"]}},
"required":["repo","number","body","severity"]},
"description":"Required (at least 1) when verdict=request_changes. The OPERATOR posts these as the SCM review and its inline comments - you do not post them yourself."}},
"required":["verdict","reviewed_shas"],"additionalProperties":false}
There is no comment verdict: a review either approves (merge proceeds) or requests changes (loop back). A non-decision has no stage to go to.
verdict=approve does not post an approving review. The platform has one bot identity, so the forge rejects a self-approve; the operator posts a COMMENT review carrying the verdict and then merges. The merge is the approval of record.
{"type":"object","properties":{
"task":{"type":"string"},
"action":{"type":"string","enum":["propose","skip"]},
"proposals":{"type":"array","minItems":1,"maxItems":5,"items":{"type":"object","properties":{
"repo":{"type":"string"},"title":{"type":"string"},"body":{"type":"string"},
"kind":{"type":"string","enum":["bug","improvement"]}},
"required":["repo","title","body","kind"]}},
"reason":{"type":"string","description":"Required when action=skip."}},
"required":["action"],"additionalProperties":false}
{"type":"object","properties":{
"task":{"type":"string"},
"action":{"type":"string","enum":["file_issue","false_positive"]},
"alert_rules":{"type":"array","minItems":1,"items":{"type":"string"}},
"issue":{"type":"object","properties":{
"repo":{"type":"string"},"title":{"type":"string"},"body":{"type":"string"}},
"required":["repo","title","body"],
"description":"Required when action=file_issue."},
"reason":{"type":"string"}},
"required":["action","alert_rules","reason"],"additionalProperties":false}
{"type":"object","properties":{
"task":{"type":"string"},
"folds":{"type":"array","items":{"type":"object","properties":{"task":{"type":"string"}},
"required":["task"]},
"description":"Member Tasks to fold in: their Issues/MRs are adopted, then the member Task is deleted. A member with a running pod is REFUSED."},
"closes":{"type":"array","items":{"type":"object","properties":{
"repo":{"type":"string"},"number":{"type":"integer"},"reason":{"type":"string"}},
"required":["repo","number","reason"]}},
"links":{"type":"array","items":{"type":"object","properties":{
"repo":{"type":"string"},"number":{"type":"integer"},"isPR":{"type":"boolean"}},
"required":["repo","number"]}}},
"required":[],"additionalProperties":false}
The other tools¶
SCM (4)¶
scm_read - repo is required on every kind:
{"type":"object","properties":{
"kind":{"type":"string","enum":["issues","mr","comments","commits","ci"],
"description":"issues|mr|comments are served from tatara's own mirror of the forge (fast, free, at most one hour stale). ci is a live read of the forge and is paced server-side to one fetch per 20s per PR."},
"project":{"type":"string"},
"repo":{"type":"string","description":"Repository CR name, e.g. tatara-operator. REQUIRED."},
"number":{"type":"integer","description":"Required for kind=ci and kind=comments."},
"is_pr":{"type":"boolean","description":"kind=comments only: read the MR thread instead of the issue thread."},
"state":{"type":"string","enum":["open","closed","merged","all"],
"description":"kind=issues (open|closed|all) and kind=mr (open|merged|closed|all)."},
"since":{"type":"string","description":"kind=issues|mr only. RFC3339."},
"labels":{"type":"string","description":"kind=issues only. Comma-separated."},
"since_days":{"type":"integer","description":"kind=commits only. Default 30."},
"limit":{"type":"integer"}},
"required":["kind","repo"],"additionalProperties":false}
issue_write - no status, no labels:
{"type":"object","properties":{
"action":{"type":"string","enum":["create","edit","close","comment"]},
"project":{"type":"string"},"repo":{"type":"string"},
"number":{"type":"integer"},"title":{"type":"string"},"body":{"type":"string"},
"comment":{"type":"string","description":"Required for action=close: every close cites its reason."}},
"required":["action","repo"],"additionalProperties":false}
mr_write - no merge, no approve, no request_changes:
{"type":"object","properties":{
"action":{"type":"string","enum":["open","comment","reply"]},
"project":{"type":"string"},"repo":{"type":"string"},
"number":{"type":"integer"},"title":{"type":"string"},"body":{"type":"string"},
"in_reply_to":{"type":"string","description":"Required for action=reply: the externalId from scm_read(kind=comments)."}},
"required":["action","repo"],"additionalProperties":false}
mr_takeover_request (implement, review only) - request ownership takeover of an external MR:
{"type":"object","properties":{
"repo":{"type":"string"},
"number":{"type":"integer"},
"comment_external_id":{"type":"string","description":"The external comment ID (from scm_read(kind=comments)) that contains the takeover request."}},
"required":["repo","number","comment_external_id"],"additionalProperties":false}
Code graph (4)¶
code_search-GET /code/entities?repo=&q=&type=&limit=; requiredrepo,q.code_context-relenumentity|neighbors|callers|callees|dependents|dependencies|file_imports|related|cross_repo; requiredrepo,rel;idis the entity id fromcode_search;depthdefault 1, max 4; extra argsrelationanddirection(out|in) forrel=neighbors;limit.code_graph-openumpath|important|stats|ambiguous|communities|hyperedges|bridges|resource_graph; requiredrepo,op;from/toforop=pathonly;communityforop=communities(reads one community instead of the list);idforop=hyperedges(reads one hyperedge instead of the list);limit.code_explain-GET /code-graph/explain?repo=&id=; requiredrepo,id.
Memory (5)¶
memory_query(POST /queries) /memory_describe(POST /queries:describe)- required
mode(enumlocal|global|hybrid|naive),text; optionaltop_k. memory_write(POST /memories) - requiredtext; optionalmetadata(string-valued object). Returns thetrack_id.memory_entity(op=get|search|patch)-oprequired.op=get/op=patchrequireid;op=patchalso requirespatch(the fields to change);op=searchrequiresq;limitapplies toop=search.memory_edges(op=list|create|delete)-oprequired.op=listtakes optionalfrom,to,relation,limit;op=createtakesfrom,to,relation;op=deleterequiresid.
Platform (7)¶
task_get, task_list, task_context(task?, index?, notes?), task_note(kind, body) (no agent argument - the operator stamps the writer), project_get, repo_list. task_note replaces the entire chat and handoff surface.
report_internal_issue(category, description, severity?, offending_tool?, resource_id?) - required category (enum tool_error, directive_contradiction, workspace_broken, memory_inconsistent, graph_inconsistent, auth, other) and description (non-empty); optional severity (warn|error, defaults error), offending_tool, and resource_id. Emits a structured ERROR log that the wrapper ships to the platform; it does NOT create a durable SCM issue. It emits no metric of its own - the cli has no metrics egress - but the wrapper's transcript tailer keys on the tool NAME and raises tatara_wrapper_internal_issue_total from the outside, which is what the alert fires on. It is therefore callable, and useful, even in a pod whose credentials never resolved.
task_context's notes argument:
{"type":"object","properties":{
"task":{"type":"string","description":"Any Task in this project. Defaults to your own."},
"index":{"type":"boolean","description":"Return the compact project-wide Task index instead of one Task's full bundle."},
"notes":{"type":"string","enum":["recent","all"],
"description":"recent (default) renders the notes in the bundle; all rehydrates the full note history, including notes spilled to memory. Use it when the <notes> marker says notes were elided."}},
"additionalProperties":false}
The gh / glab ban, with the workstation carve-out¶
In-cluster agent pods may not use gh or glab, and may not merge
This is enforced structurally: the pod has no forge token of its own for these paths, and the MCP profile exposes no merge action. validate_skills.py greps for gh and glab in any skill body.
Workstation skills keep gh and keep human-driven merge
start-development and everything it drives is run by a human at a terminal with their own gh auth. It does not go through MCP profile gating, and the ban's enforcement mechanism does not reach it - so the ban does not apply to it. The rule is about what an autonomous pod may do with the platform's bot identity, not about what a human may do with their own.
The tool-manifest drift lint¶
validate_skills.py's checks (frontmatter, the gh/glab ban, one hand-crafted merge-instruction regex) never verified that a documented call like submit_outcome(action="approved") or mr_write(action="open") still matches this page's schema. A renamed tool or a stale enum value read fine to a human reviewer and failed only at agent runtime.
tatara-cli closes that gap by publishing a tool-manifest.json release asset (tatara tool-manifest, see tatara-cli) - every registered tool's name and top-level enum fields, generated straight from the internal/mcp/outcome.go / internal/mcp/tools_scm.go schema consts. tatara-agent-skills CI runs .github/scripts/validate_tool_calls.py alongside validate_skills.py, fetching the manifest version pinned in .github/tool-manifest-version and checking every literal tool_name(field="value") token in a SKILL.md or agent file against it.
Scope is deliberately narrow: only complete field="literal" tokens are checked. Bare signatures (code_search(repo, q, type, limit)) and elided prose (body=...) have no ="literal" shape and are silently skipped, and a line naming a value only to forbid it (Do NOT attempt mr_write(action="approve"|"request_changes"|"merge")) is exempted by a negation-word heuristic shared with validate_skills.py. This avoids the false-positive storm a full JSON-schema conformance check would cause on skills' many illustrative, non-literal examples - at the cost of not catching required-field or shape errors, only unknown tool names and stale enum values.
A manifest fetch failure (network, 404, no release published yet) is a soft warning, not a CI failure - a missing manifest never blocks an unrelated skill PR.