Python API

MANNA is used through MCP, not as a library, but the modules below are stable enough to read and useful for contributors. Tools never import pyvo directly; every archive call goes through manna.backends.

Server

manna.app.build_mcp()[source]

Construct the FastMCP server with all tools registered.

Every tool is read-only (the server never mutates archive state) except abort_async_job, which deletes an upstream UWS job. Tools that hit live archive services are open-world; list_archives is the one closed-world archive-notes reader (describe_table left that set when it grew a live column fetch).

Return type:

FastMCP

manna.app.build_app()[source]
Return type:

Starlette

Connections (manna.backends)

IVOA TAP backend: sync + async wrappers over pyvo.

This module is the ONLY place that imports pyvo. Tools work in terms of job_url strings and astropy.Table — they do not touch AsyncTAPJob.

manna.backends.tap.job_error_message(job)[source]

The upstream UWS errorSummary message for a job, or None if there is none.

pyvo’s AsyncTAPJob has no public accessor for this: the parsed UWS tree hangs off job._job, and the text sits at errorsummary.message.content. This is the exact traversal pyvo’s own raise_if_error() performs; it lives here so the tools (which never import pyvo) get the message through one seam instead of guessing at attribute names — the previous getattr(job, “error_summary”) read an attribute that never existed and silently discarded every archive’s diagnostic for months.

Parameters:

job (AsyncTAPJob)

Return type:

str | None

manna.backends.tap.job_phase(job)[source]

The phase pyvo parsed when the job was last fetched, without refetching.

AsyncTAPJob.phase is a property that issues a new GET on every access; the constructor has already fetched the job, so the cached UWS tree at job._job is current for the read that just happened. Test doubles that carry a plain phase attribute and no cached tree fall through to it.

Parameters:

job (AsyncTAPJob)

Return type:

str

class manna.backends.tap.TapClient(*, sync_timeout_seconds=20.0)[source]

Bases: object

Wrapper around pyvo.dal.TAPService + AsyncTAPJob.

All pyvo exceptions are mapped to project ToolExecutionError subclasses at the boundary; tools never see DAL errors directly.

Parameters:

sync_timeout_seconds (float)

query(*, endpoint, adql, maxrec=10000)[source]

Synchronous TAP query. Bounded by sync_timeout_seconds (default 20s).

Raises:
  • DalQueryError on bad ADQL. –

  • ArchiveError on service errors / timeouts / unreachable hosts. –

Parameters:
Return type:

Table

submit_async(*, endpoint, adql, maxrec=10000)[source]

Submit a TAP query as an async UWS job and start execution.

Returns the absolute job_url. The caller (the LLM client holding the returned job_url) is the job’s lifetime owner; this method records no server-side state.

IMPORTANT: pyvo’s AsyncTAPJob defaults to delete=True, which registers an atexit-style cleanup that DELETEs the upstream job when the Python object is garbage-collected. We MUST disable that. Setting _delete_on_exit = False before the local reference falls out of scope prevents the upstream job from being silently destroyed between tool calls.

Parameters:
Return type:

str

load_job(job_url)[source]

Re-hydrate an AsyncTAPJob from a previously-stored job_url.

delete=False is critical — without it, the AsyncTAPJob’s __del__ would DELETE the upstream job as soon as the caller (a tool function) returned. See submit_async docstring.

Parameters:

job_url (str)

Return type:

AsyncTAPJob

abort_job(job_url)[source]

Send UWS DELETE for a job. Idempotent: 404 / connection-refused on a previously-deleted job is swallowed so callers can abort twice without branching.

We construct the AsyncTAPJob directly here (instead of via load_job) so that DALServiceError from the construction-time GET — which happens on already-deleted jobs — is caught and swallowed at the same level as failures from job.delete().

Parameters:

job_url (str)

Return type:

None

class manna.backends.sia.SiaClient[source]

Bases: object

Sync wrapper over pyvo SIA — SIA 2.0 by default, SIA 1.0 fallback.

Most modern archives (ALMA, CADC, ESO) speak SIA2. A few major ones — notably NOIRLab Astro Data Lab — only expose SIA v1. version=’auto’ tries SIA2 first and, when its capabilities probe fails (a DALAccessError, the tell that the endpoint isn’t SIA2), retries the same URL as SIA1.

search(*, endpoint, ra, dec, size_deg, band=None, fmt=None, maxrec=1000, version='auto')[source]
Parameters:
Return type:

Table

class manna.backends.cone.ConeSearchClient[source]

Bases: object

Sync wrapper over pyvo.dal.SCSService.search().

search(*, endpoint, ra, dec, radius_deg, maxrec=10000)[source]
Parameters:
Return type:

Table

class manna.backends.registry.RegistryClient[source]

Bases: object

Sync wrapper over pyvo.registry. No async.

Two operations:
  • search(keywords/servicetype/waveband) -> list of service dicts

  • describe(ivoid_or_url) -> service dict with capabilities + tables

search(*, keywords=None, servicetype=None, waveband=None, maxrec=50)[source]
Parameters:
Return type:

list[dict]

describe(*, ivoid_or_url)[source]
Parameters:

ivoid_or_url (str)

Return type:

dict

Name resolver backend — wraps CDS Sesame via astropy.coordinates.

class manna.backends.resolver.ResolverClient[source]

Bases: object

Wraps CDS Sesame (SIMBAD → NED → VizieR fallback chain) for name resolution.

resolve(name)[source]

Return (ra_deg, dec_deg) in ICRS, or None if the name is unknown.

Raises ArchiveError for network / service failures that are distinct from a simple name-not-found response. cache=False ensures the HTTP round-trip always occurs so vcrpy can intercept it during test replay.

Parameters:

name (str)

Return type:

tuple[float, float] | None

Result handling (manna.results)

Result handling — inline small results; link + pyvo fetch recipe for large ones.

Envelopes (shape_*), the sync→async promotion envelope, and the query_fingerprint / save_recipe cache fields all live here.

manna.results.shape_inline_table(table, *, archive, maxrec)[source]

Convert an astropy.Table into the inline response envelope.

Rows flow inline. Callers that must NOT inline large results (TAP) check is_oversize first and route to the async/result-URL path; discovery tools (cone / SIA search) inline up to the cap and let shape_table truncate the tail.

Parameters:
Return type:

dict[str, Any]

manna.results.shape_table(table, *, archive, maxrec)[source]

Inline envelope for discovery tools (cone / SIA search).

These are synchronous DAL queries with no async job or persistent archive URL to hand back, so a result larger than the inline cap is truncated inline with truncated=true and a hint to narrow the search. TAP does NOT use this path — it checks is_oversize and routes oversize results to the async / result-URL flow instead.

Parameters:
Return type:

dict[str, Any]

manna.results.is_oversize(table)[source]

True if table would exceed the inline row OR byte cap.

TAP tools call this to decide between inlining a result and routing it to the async / result-URL flow. Cheap: the byte estimate only runs when the row count is already within the row cap.

Parameters:

table (Table)

Return type:

bool

manna.results.build_fetch_recipe(job_url, result_url=None)[source]

Client-side recipe for loading an async TAP result with pyvo.

The server never fetches the result bytes; it hands the LLM the upstream job URL and the code to load it in the user’s own Python environment (e.g. a Jupyter kernel). Anonymous access only.

Parameters:
Return type:

dict[str, Any]

manna.results.build_load_recipe(*, endpoint, adql)[source]

Client-side recipe re-executing a sync TAP query in the user’s kernel.

Inline rows in the envelope are for the MODEL to read; getting them into a notebook by pasting literals truncates tool calls past ~100 rows (observed live 2026-08-11: edit_cell arrived with empty args). This recipe is the size-independent transport — the kernel re-runs the same sync query directly against the archive (constant-size cell, same trade as auto-promote’s discarded first execution) and binds table + df.

Parameters:
Return type:

dict[str, Any]

manna.results.build_save_recipe(*, fingerprint, tool, endpoint, archive, query, truncated, maxrec=None)[source]

Client-side recipe for persisting a query result as a CSV + catalog row.

Same philosophy as build_fetch_recipe: the server never touches the user’s filesystem — it hands the LLM a self-contained snippet to run in the user’s own Python environment. The snippet assumes the result is in a pandas DataFrame named df (built from the inline rows or loaded via fetch_recipe), writes manna_cache/<fingerprint>.csv, and appends a metadata row to manna_cache/catalog.csv.

Catalog appends go through csv.QUOTE_ALL — ADQL is full of commas and quotes, and a hand-concatenated row would corrupt the catalog. The target column is left empty for the agent to fill when it resolved a target name this conversation; saved_at is stamped client-side at execution time. The embedded values use !r so the generated snippet stays valid Python for any query text. maxrec records the upstream row cap that was in effect (or ‘’ when unknown, e.g. from get_async_job_results) — without it, a result capped upstream catalogs as truncated=False with no record of the cap, and a capped CSV can be mistaken for the full result.

The catalog append is deliberately append-only, not idempotent. Models retype this snippet by hand into notebook cells rather than executing it verbatim (observed live), and the previous read-filter-rewrite version was long enough that retyping corrupted it in roughly a third of runs — a dropped header line in one, a SyntaxError in another (observed live 2026-08-11). Every physical line is a retype hazard, so this version just appends: it can produce duplicate rows for the same fingerprint (rerunning the save cell, or refreshing the same query later). That’s accepted here — READERS must dedupe by taking the newest (last) row per fingerprint; that rule lives in the deployment persona, not in this snippet.

Parameters:
Return type:

dict[str, Any]

manna.results.attach_cache_fields(envelope, *, fingerprint, tool, endpoint, query, maxrec=None, load_recipe=None)[source]

Add query_fingerprint + save_recipe (+ optional load_recipe) to a success envelope (mutates).

Called by the primitive query tools (TAP/cone/SIA) after shaping. archive and truncated are read off the envelope so the recipe’s catalog row always matches what the envelope claims — a truncated inline result is recorded as truncated and never mistaken for the full result by the client-side cache. maxrec is the upstream row cap in effect for this query (None when unknown, e.g. get_async_job_results, where the original maxrec isn’t recoverable from the job). Error payloads never pass through here (wrap_tool_errors short-circuits before shaping).

load_recipe (from build_load_recipe) is passed by the inline sync TAP paths only — it re-executes the query kernel-side so the client never has to paste inline rows into a cell to build df. When None (the async get_async_job_results path, where fetch_recipe already covers loading), no load_recipe key is added to the envelope.

When load_recipe is given, its code is fused with the save snippet’s code (load lines, then save lines, joined with a newline) — saving becomes a side effect of running load_recipe.code, not a separate cell the model can skip. Live runs showed models executing load_recipe + plotting but never running the standalone save cell at all (observed live, roughly half of runs) — a save action described as a second, later step is easy to drop once the model has what it wanted (df). save_recipe is still attached standalone too: async results load via fetch_recipe (not load_recipe) and still need it, and it covers a cache-only re-save of already-loaded data.

Also appends an imperative save instruction to the envelope’s top-level next_steps — nested fields like save_recipe.instructions get ignored by weak models (the PR-#64 lesson, observed live with a small local model, 2026-08-10), so the save action must live where those models reliably read it.

Parameters:
Return type:

dict[str, Any]

manna.results.shape_result_url(*, job_url, result_url, archive, phase='COMPLETED')[source]

Envelope for a COMPLETED async TAP job.

Carries the upstream job URL, the direct result URL, and a pyvo fetch recipe. No result bytes flow through the server — the client loads the data itself.

next_steps is written for the weakest reader: imperative, names the model’s own code-execution tool, and forbids re-running the query. Small models (observed: small local models behind the Jupyter AI persona) treat descriptive phrasing like “fetch client-side” as someone else’s job and abandon the completed result.

Parameters:
Return type:

dict[str, Any]

manna.results.shape_registry_search_result(services, *, maxrec)[source]

Envelope for search_ivoa_registry results.

Parameters:
Return type:

dict

manna.results.shape_registry_describe_result(described, *, table_filter=None)[source]

Envelope for describe_ivoa_service.

When table_filter is given, the table set is narrowed (case-insensitive substring over name/description) before shaping — a narrow filter yields a small set, so full per-column detail fits inline and the model gets exactly the tables it wants, with columns, in one call.

Otherwise: small services pass through with full per-column detail for every table. Large services (tables x columns — e.g. Gaia/Data Lab) would overflow the model context, so the response degrades to a table catalog: every table keeps its name, (clipped) description, and column_count, but the per-column arrays are dropped. truncated discloses the degradation; a hint points the model at the per-table columns drill-down and at table_filter for narrowing.

Always carries top-level truncated (project contract) and total_tables; matched_tables is added when a filter is applied.

Parameters:
Return type:

dict

manna.results.shape_promotion(*, job_url, archive, phase, submitted_at)[source]

Envelope returned when run_adql_query goes async (explicit mode=async, auto-mode timeout fallback, or an oversize sync result). The server has already waited its default window, so phase may be COMPLETED, in which case next_steps sends the model straight to get_async_job_results.

Shape-disjoint from the inline tabular envelope: there are no rows. The LLM branches on the literal mode: “async”.

The upstream job_url is the job’s only handle — pass it back to get_async_job_status / get_async_job_results / abort_async_job. There is deliberately no server-side job id: the server holds no per-job state, so nothing in this process can be reached by a caller who did not submit the job.

Parameters:
Return type:

dict[str, Any]

Errors (manna.errors)

exception manna.errors.ToolExecutionError(error_class='internal_error', retry_strategy='abandon', message='', hint=None, retry_after_seconds=None, request_id=None)[source]

Bases: Exception

Base class — every concrete subclass sets error_class + retry_strategy.

Subclasses that should have their message redacted in the LLM-facing payload (e.g. internal server errors) override redact_message to True.

Parameters:
  • error_class (str)

  • retry_strategy (Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'])

  • message (str)

  • hint (str | None)

  • retry_after_seconds (int | None)

  • request_id (str | None)

Return type:

None

redact_message: ClassVar[bool] = False
error_class: str = 'internal_error'
retry_strategy: Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'] = 'abandon'
message: str = ''
hint: str | None = None
retry_after_seconds: int | None = None
request_id: str | None = None
exception manna.errors.ValidationError(error_class='validation_error', retry_strategy='fix_and_retry', message='', hint=None, retry_after_seconds=None, request_id=None)[source]

Bases: ToolExecutionError

Parameters:
  • error_class (str)

  • retry_strategy (Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'])

  • message (str)

  • hint (str | None)

  • retry_after_seconds (int | None)

  • request_id (str | None)

Return type:

None

error_class: str = 'validation_error'
retry_strategy: Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'] = 'fix_and_retry'
exception manna.errors.ArchiveError(error_class='archive_error', retry_strategy='wait_and_retry', message='', hint=None, retry_after_seconds=None, request_id=None)[source]

Bases: ToolExecutionError

Parameters:
  • error_class (str)

  • retry_strategy (Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'])

  • message (str)

  • hint (str | None)

  • retry_after_seconds (int | None)

  • request_id (str | None)

Return type:

None

error_class: str = 'archive_error'
retry_strategy: Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'] = 'wait_and_retry'
exception manna.errors.TimeoutArchiveError(error_class='archive_error', retry_strategy='wait_and_retry', message='', hint=None, retry_after_seconds=None, request_id=None)[source]

Bases: ArchiveError

A sync request that exceeded the client timeout.

Distinct Python type so run_adql_query’s auto-promote path can branch on the failure mode directly, instead of substring-matching the error message. The wire contract is unchanged: error_class stays ‘archive_error’ and a sync-mode caller sees an ordinary archive_error.

Parameters:
  • error_class (str)

  • retry_strategy (Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'])

  • message (str)

  • hint (str | None)

  • retry_after_seconds (int | None)

  • request_id (str | None)

Return type:

None

exception manna.errors.DalQueryError(error_class='tap_query_error', retry_strategy='fix_and_retry', message='', hint=None, retry_after_seconds=None, request_id=None)[source]

Bases: ToolExecutionError

Parameters:
  • error_class (str)

  • retry_strategy (Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'])

  • message (str)

  • hint (str | None)

  • retry_after_seconds (int | None)

  • request_id (str | None)

Return type:

None

error_class: str = 'tap_query_error'
retry_strategy: Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'] = 'fix_and_retry'
exception manna.errors.JobNotReadyError(error_class='job_not_ready', retry_strategy='poll', message='', hint=None, retry_after_seconds=None, request_id=None)[source]

Bases: ToolExecutionError

Raised by get_async_job_results when the job exists but hasn’t finished yet.

The retry_strategy “poll” tells the LLM to call get_async_job_status in a loop instead of retrying get_async_job_results immediately.

Parameters:
  • error_class (str)

  • retry_strategy (Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'])

  • message (str)

  • hint (str | None)

  • retry_after_seconds (int | None)

  • request_id (str | None)

Return type:

None

error_class: str = 'job_not_ready'
retry_strategy: Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'] = 'poll'
exception manna.errors.JobGoneError(error_class='job_gone', retry_strategy='abandon', message='', hint=None, retry_after_seconds=None, request_id=None)[source]

Bases: ToolExecutionError

The upstream job no longer exists (UWS 404/410).

Distinct from ArchiveError because the retry advice is the opposite: a job that has been deleted or aged out of the archive’s UWS store will never come back, so ‘wait_and_retry’ would loop the model forever. Since the server keeps no job registry, this upstream status is the only signal that a job_url is dead.

Parameters:
  • error_class (str)

  • retry_strategy (Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'])

  • message (str)

  • hint (str | None)

  • retry_after_seconds (int | None)

  • request_id (str | None)

Return type:

None

error_class: str = 'job_gone'
retry_strategy: Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'] = 'abandon'
exception manna.errors.InternalError(error_class='internal_error', retry_strategy='abandon', message='', hint=None, retry_after_seconds=None, request_id=None)[source]

Bases: ToolExecutionError

Parameters:
  • error_class (str)

  • retry_strategy (Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'])

  • message (str)

  • hint (str | None)

  • retry_after_seconds (int | None)

  • request_id (str | None)

Return type:

None

redact_message: ClassVar[bool] = True
error_class: str = 'internal_error'
retry_strategy: Literal['fix_and_retry', 'wait_and_retry', 'submit_async', 'abandon', 'poll'] = 'abandon'
manna.errors.error_to_payload(err, *, request_id=None)[source]

Convert any error into the LLM-facing payload shape.

Unknown exceptions (anything not a ToolExecutionError) are coerced into InternalError; the original exception is attached as __cause__ so the caller’s logger can still recover it.

The LLM-facing payload omits hint and retry_after_seconds when not present to save tokens; callers should use payload.get(…) on those keys.

Parameters:
Return type:

dict

manna.errors.wrap_tool_errors(fn)[source]

Decorate an MCP tool so all errors flow through the structured payload path.

Catches ToolExecutionError subclasses (preserving error_class/retry_strategy) and bare Exception (coerced to InternalError with redacted message). Threads the current request_id from the ContextVar into the payload. Logs at WARN level for expected errors, EXCEPTION for unexpected.

Parameters:

fn (Callable)

Return type:

Callable

Settings (manna.config)

class manna.config.Settings(_case_sensitive=None, _nested_model_default_partial_update=None, _env_prefix=None, _env_prefix_target=None, _env_file=PosixPath('.'), _env_file_encoding=None, _env_ignore_empty=None, _env_nested_delimiter=None, _env_nested_max_split=None, _env_parse_none_str=None, _env_parse_enums=None, _cli_prog_name=None, _cli_parse_args=None, _cli_settings_source=None, _cli_parse_none_str=None, _cli_hide_none_type=None, _cli_avoid_json=None, _cli_enforce_required=None, _cli_use_class_docs_for_groups=None, _cli_exit_on_error=None, _cli_prefix=None, _cli_flag_prefix_char=None, _cli_implicit_flags=None, _cli_ignore_unknown_args=None, _cli_kebab_case=None, _cli_shortcuts=None, _secrets_dir=None, _build_sources=None, **values)[source]

Bases: BaseSettings

Parameters:
model_config = {'arbitrary_types_allowed': True, 'case_sensitive': False, 'cli_avoid_json': False, 'cli_enforce_required': False, 'cli_exit_on_error': True, 'cli_flag_prefix_char': '-', 'cli_hide_none_type': False, 'cli_ignore_unknown_args': False, 'cli_implicit_flags': False, 'cli_kebab_case': False, 'cli_parse_args': None, 'cli_parse_none_str': None, 'cli_prefix': '', 'cli_prog_name': None, 'cli_shortcuts': None, 'cli_use_class_docs_for_groups': False, 'enable_decoding': True, 'env_file': '.env', 'env_file_encoding': 'utf-8', 'env_ignore_empty': False, 'env_nested_delimiter': None, 'env_nested_max_split': None, 'env_parse_enums': None, 'env_parse_none_str': None, 'env_prefix': 'MANNA_', 'env_prefix_target': 'variable', 'extra': 'ignore', 'json_file': None, 'json_file_encoding': None, 'nested_model_default_partial_update': False, 'protected_namespaces': ('model_validate', 'model_dump', 'settings_customise_sources'), 'secrets_dir': None, 'toml_file': None, 'validate_default': True, 'yaml_config_section': None, 'yaml_file': None, 'yaml_file_encoding': None}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

host: str
port: int
archives: str | None
log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR']
allowed_hosts: str | None
tap_sync_timeout_seconds: float
count_async_budget_seconds: float
count_async_poll_interval_seconds: float
async_wait_seconds: float
async_wait_max_seconds: float
inline_row_limit: int
inline_byte_limit: int
registry_describe_byte_limit: int
manna.config.get_settings()[source]

Process-wide Settings singleton.

Cached so runtime consumers (the lazy backend accessors, the URL guard) read environment / .env once rather than re-parsing per call. Tests that mutate the environment must call get_settings.cache_clear() to force a re-read.

Return type:

Settings

The underscore-prefixed modules below (manna.archives._model, manna.archives._audit) are internal but stable enough to read; they are not a supported import surface.

Archive notes model (manna.archives)

Archive model — the dataclasses one archive’s knowledge is built from.

An archive is the portable, plugin-style unit of archive notes: its identity + endpoints + usage_notes, together with the per-table Schema entries for that same archive. One archive = one file under archives/.

Archive and Schema live here so the model is a dependency-free leaf; the endpoint/schema helpers in archives/_endpoints.py and archives/_knowledge.py import them from here. Note (one atomic curated claim) and its Audit (how the live runner re-checks it) also live here — every usage_notes / Schema.notes entry is a Note, no other form accepted.

class manna.archives._model.Pitfall(guidance, triggers=())[source]

Bases: object

A pitfall: how a note’s claim gets DELIVERED to the model, and when.

A note in list_archives is knowledge the model can reach. A pitfall is knowledge we push at it, because the eval showed reachable isn’t enough (issue #57: the NRAO LOWER/UPPER note was true, probed, and served — and the model still wrote LOWER()). Like Audit, this is declarative: it carries no delivery code. archives/_pitfalls.py reads these.

Two kinds, split by whether the model can self-correct from the failure, and told apart entirely by triggers:

  • up-front note (no triggers; called silent in the code) — the model gets NO usable correction signal, so the claim must arrive BEFORE the query. Either the query silently returns a wrong answer (ALMA: COUNT(*) over-counts, no error) or it errors so cryptically that the message doesn’t imply the fix (Data Lab: ADQL geometry surfaces as function point(…) does not exist, which never suggests q3c). These go in the run_adql_query description — the expensive channel, re-sent every turn, so the bar is high and guidance must be terse.

  • error hint (triggers set; called loud in the code) — the query throws, and the triggers recognise the cause in the submitted ADQL. guidance rides the error payload’s hint instead, so it costs nothing until it fires.

guidance is the compact, imperative fix — not the note’s full prose.

Parameters:
guidance: str
triggers: tuple[str, ...] = ()
property channel: Literal['upfront', 'error_hint']

Up-front notes have no triggers and ride the tool description; error hints have triggers and ride the failure payload’s hint.

fires_on(adql)[source]

Whether adql hits this pitfall. Up-front notes never fire (no triggers).

Parameters:

adql (str)

Return type:

bool

class manna.archives._model.Note(id, text, audit, pitfall=None)[source]

Bases: object

One ATOMIC curated claim + the audit that re-checks it live.

id is a stable slug, unique within its owning archive — the address a stale audit prints so you can jump straight to the note to fix. text is the single-claim, LLM-facing prose surfaced by list_archives / describe_table. audit (mandatory) is how the live runner re-checks it. pitfall (optional) opts the claim into a push channel — see Pitfall.

Parameters:
id: str
text: str
audit: Audit
pitfall: Pitfall | None = None
manna.archives._model.note_texts(notes)[source]

The LLM-facing strings for a tuple of notes, in order. Audit stays internal.

Parameters:

notes (tuple[Note, ...])

Return type:

list[str]

class manna.archives._model.Schema(archive, table, missing_standard_columns=(), value_enums=<factory>, notes=(), cross_refs=())[source]

Bases: object

Archive notes about ONE table at one archive.

archive is the owning archive’s short_name. It is redundant with the owning Archive.short_name (validated in Archive.__post_init__) but kept because it is part of the describe_table response contract and lets cross_refs name tables as (archive, table) pairs.

Parameters:
archive: str
table: str
missing_standard_columns: tuple[str, ...] = ()
value_enums: dict[str, tuple[str, ...]]
notes: tuple[Note, ...] = ()
cross_refs: tuple[tuple[str, str], ...] = ()
class manna.archives._model.Archive(short_name, display_name, host_substrings, tap_url=None, sia_url=None, scs_url=None, waveband=None, description='', notable_tables=<factory>, usage_notes=<factory>, schemas=<factory>, count_target=None, priority=100, paused=None)[source]

Bases: object

Everything the server knows about ONE IVOA archive, in one place.

Identity + best-effort endpoints (tap_url / sia_url / scs_url are None when the archive doesn’t expose one we surface), plus:

  • usage_notes — short agent-facing strings capturing archive-specific gotchas (non-standard table locations, sync-vs-async routing, ADQL quirks, target-name conventions). Surfaced via list_archives.

  • schemas — curated per-table Schema facts for this archive. Surfaced via describe_table; not echoed by list_archives.

  • count_target — optional CountTarget: how to build a positional COUNT for this archive’s primary table (used by count_observations_near_target / survey_archives_for_target). None ⇒ not directly countable (still reachable via the atomic tools).

  • priority — ascending sort key (ties broken by short_name). The explicit replacement for the old “declaration order is load-bearing” convention: the first TAP-having archives become the endpoint examples shown to the LLM, so lower numbers are the archives we steer toward.

  • paused — None (default) or a dated, one-sentence reason. Non-None means the archive ships and is discoverable but is left OUT of the default active set; the reason is logged at boot. Naming the archive in MANNA_ARCHIVES activates it regardless — pausing is a default, not a lock. Use it when an archive’s service is being rebuilt or asks for less traffic, and you want to keep its notes for when it returns.

An archive is discovered by the registry (see archives/__init__.py) as the module-level ARCHIVE in an archives/<short_name>.py file. Dropping the file (or excluding it via MANNA_ARCHIVES) removes the server’s claims about that archive — never its reachability. See docs/archives-spec.md.

Parameters:
short_name: str
display_name: str
host_substrings: tuple[str, ...]
tap_url: str | None = None
sia_url: str | None = None
scs_url: str | None = None
waveband: str | None = None
description: str = ''
notable_tables: tuple[str, ...]
usage_notes: tuple[Note, ...]
schemas: tuple[Schema, ...]
count_target: CountTarget | None = None
priority: int = 100
paused: str | None = None

Audit — the falsifiable probe (or manual marker) a Note carries.

An Audit is the declarative half of a curated claim: how to re-check it live. It carries NO network code — the live runner (evals/audit.py) reads these and executes them. Keeping Audit pure keeps the model layer dependency-free.

expect reuses the caveat vocabulary the probe engine already understands:

ok | error | empty | nonempty | count | manual

manual marks a claim that a single ADQL probe can’t check (download recipes, naming conventions, async-only behaviours) — it must carry a reason.

manna.archives._audit.has_table(table)[source]

ADQL asking whether table is present in tap_schema.tables.

Parameters:

table (str)

Return type:

str

manna.archives._audit.has_cols(table, columns)[source]

ADQL selecting which of columns are present on table.

Parameters:
Return type:

str

class manna.archives._audit.Audit(expect, adql='', columns=(), reason='')[source]

Bases: object

Parameters:
expect: str
adql: str = ''
columns: tuple[str, ...] = ()
reason: str = ''
classmethod probe(*, expect, adql)[source]
Parameters:
Return type:

Audit

classmethod count(*, table, columns)[source]
Parameters:

table (str)

Return type:

Audit

classmethod manual(reason)[source]
Parameters:

reason (str)

Return type:

Audit

Archive registry — discovery, selection, and the active-set accessor.

Each archives/<short_name>.py module exports a module-level ARCHIVE of type Archive. This package discovers every archive physically present, validates the full set, then narrows to the deployment’s active set via MANNA_ARCHIVES (an optional comma-separated allow-list of short_names; unset/empty => all).

Two ways to shape a deployment:

  • Physical — delete an archives/<name>.py file (forking a deployment).

  • Runtime — set MANNA_ARCHIVES=datalab,alma from a shared image.

Archives here are purely additive: absence removes the server’s claims about an archive (usage_notes, schema quirks, endpoint examples, cosmetic label), never its reachability. See docs/archives-spec.md.

get_active_archives() is cached like get_settings(); tests that toggle the environment call get_active_archives.cache_clear().

class manna.archives.Archive(short_name, display_name, host_substrings, tap_url=None, sia_url=None, scs_url=None, waveband=None, description='', notable_tables=<factory>, usage_notes=<factory>, schemas=<factory>, count_target=None, priority=100, paused=None)[source]

Bases: object

Everything the server knows about ONE IVOA archive, in one place.

Identity + best-effort endpoints (tap_url / sia_url / scs_url are None when the archive doesn’t expose one we surface), plus:

  • usage_notes — short agent-facing strings capturing archive-specific gotchas (non-standard table locations, sync-vs-async routing, ADQL quirks, target-name conventions). Surfaced via list_archives.

  • schemas — curated per-table Schema facts for this archive. Surfaced via describe_table; not echoed by list_archives.

  • count_target — optional CountTarget: how to build a positional COUNT for this archive’s primary table (used by count_observations_near_target / survey_archives_for_target). None ⇒ not directly countable (still reachable via the atomic tools).

  • priority — ascending sort key (ties broken by short_name). The explicit replacement for the old “declaration order is load-bearing” convention: the first TAP-having archives become the endpoint examples shown to the LLM, so lower numbers are the archives we steer toward.

  • paused — None (default) or a dated, one-sentence reason. Non-None means the archive ships and is discoverable but is left OUT of the default active set; the reason is logged at boot. Naming the archive in MANNA_ARCHIVES activates it regardless — pausing is a default, not a lock. Use it when an archive’s service is being rebuilt or asks for less traffic, and you want to keep its notes for when it returns.

An archive is discovered by the registry (see archives/__init__.py) as the module-level ARCHIVE in an archives/<short_name>.py file. Dropping the file (or excluding it via MANNA_ARCHIVES) removes the server’s claims about that archive — never its reachability. See docs/archives-spec.md.

Parameters:
short_name: str
display_name: str
host_substrings: tuple[str, ...]
tap_url: str | None = None
sia_url: str | None = None
scs_url: str | None = None
waveband: str | None = None
description: str = ''
notable_tables: tuple[str, ...]
usage_notes: tuple[Note, ...]
schemas: tuple[Schema, ...]
count_target: CountTarget | None = None
priority: int = 100
paused: str | None = None
class manna.archives.Audit(expect, adql='', columns=(), reason='')[source]

Bases: object

Parameters:
expect: str
adql: str = ''
columns: tuple[str, ...] = ()
reason: str = ''
classmethod probe(*, expect, adql)[source]
Parameters:
Return type:

Audit

classmethod count(*, table, columns)[source]
Parameters:

table (str)

Return type:

Audit

classmethod manual(reason)[source]
Parameters:

reason (str)

Return type:

Audit

class manna.archives.Note(id, text, audit, pitfall=None)[source]

Bases: object

One ATOMIC curated claim + the audit that re-checks it live.

id is a stable slug, unique within its owning archive — the address a stale audit prints so you can jump straight to the note to fix. text is the single-claim, LLM-facing prose surfaced by list_archives / describe_table. audit (mandatory) is how the live runner re-checks it. pitfall (optional) opts the claim into a push channel — see Pitfall.

Parameters:
id: str
text: str
audit: Audit
pitfall: Pitfall | None = None
class manna.archives.Schema(archive, table, missing_standard_columns=(), value_enums=<factory>, notes=(), cross_refs=())[source]

Bases: object

Archive notes about ONE table at one archive.

archive is the owning archive’s short_name. It is redundant with the owning Archive.short_name (validated in Archive.__post_init__) but kept because it is part of the describe_table response contract and lets cross_refs name tables as (archive, table) pairs.

Parameters:
archive: str
table: str
missing_standard_columns: tuple[str, ...] = ()
value_enums: dict[str, tuple[str, ...]]
notes: tuple[Note, ...] = ()
cross_refs: tuple[tuple[str, str], ...] = ()
manna.archives.discover_archives()[source]

Import every archive module in this package and collect its ARCHIVE.

Modules whose name starts with _ are skipped: the leaf dataclasses and pure helpers (_model, _select, _audit) plus the derived-view helper modules (_endpoints, _knowledge) — none of them define an ARCHIVE. A non-underscore module without an ARCHIVE attribute is a developer error and raises. The returned tuple is validated (_select.validate_archives()) and sorted by (priority, short_name).

Return type:

tuple[Archive, ...]

manna.archives.get_active_archives()[source]

The archives active for this deployment, ordered.

Discovers all physically-present archives, then narrows by MANNA_ARCHIVES (unset/empty => all). Resolved once per process; frozen at first call.

Return type:

tuple[Archive, ...]