Skip to content

feat: osa manifest command + clean convention contract - #13

Merged
rorybyrne merged 2 commits into
mainfrom
feat/manifest-command-and-contract
Jul 22, 2026
Merged

feat: osa manifest command + clean convention contract#13
rorybyrne merged 2 commits into
mainfrom
feat/manifest-command-and-contract

Conversation

@rorybyrne

Copy link
Copy Markdown
Contributor

What

Two changes that ship together:

  1. osa manifest command — emits a convention's authored definition as JSON. A consumer in a different interpreter (notably the Amacrin CLI installed as an isolated uv tool, which cannot import the convention) can now get the manifest via uv run osa manifest in the project env, instead of importing the convention or reaching into osa-py internals.

  2. Clean convention model — definition and build artifact are now separated:

    • config/limits are authored, on each component; release is a pure nested build artifact {image, digest, source_ref}, absent before a build.
    • Hooks and ingesters are symmetric — both a Component (name, config, limits, optional nested release); a hook adds feature, an ingester adds schedule/initial_run.
    • One typed model tree, serialized at the edge with model_dump(by_alias=True, exclude_none=True). Removed the old two-layer domain/wire split and all four @model_serializers (including FieldDefinition's).

Why

The old wire buried authored config/limits inside the build release, which forced a two-model design and hand-written serializers to reproduce an inconsistent null/absent wire. Moving authored fields onto the component makes the model self-consistent, so plain model_dump(exclude_none=True) produces the wire.

Contract coupling

Pairs with the OSA server contract change (config/limits on the hook, release = {image, digest, source_ref}). Breaking pair — deploy together.

Tests

359 pass, ruff + ty clean. Added TestConventionManifest (component-base symmetry, uniform bind_releases, release-less vs bound wire, recursive no-null invariant); updated deploy/docs-gate/field-definition tests. Verified byte round-trip: an osa deploy body validates against the new server DTO.

🤖 Generated with Claude Code

Add a public `osa manifest` command that emits a convention's authored
definition as JSON, so a consumer in a different interpreter (e.g. the
Amacrin CLI installed as an isolated tool) can obtain the manifest via
`uv run osa manifest` instead of importing the convention.

Restructure the convention model so definition and build artifact are
cleanly separated:
- config/limits are authored, on each component; `release` is a pure
  nested build artifact {image, digest, source_ref}, absent pre-build.
- hooks and ingesters are symmetric: both a `Component` (name, config,
  limits, optional nested release), hook adds `feature`, ingester adds
  schedule/initial_run.
- one typed model tree, serialized at the edge with
  model_dump(by_alias=True, exclude_none=True) — no custom serializers,
  no domain/wire two-layer.

FieldDefinition drops its serializer (edge exclude_none omits absents).

Pairs with the OSA server contract change (config/limits on the hook,
release = {image, digest, source_ref}) — deploy them together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rorybyrne

Copy link
Copy Markdown
Contributor Author

⚠️ Breaking pair with the OSA server contract change: opensciencearchive/server#170. Deploy together.

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a portable convention manifest command and aligns deploy payloads with the new component contract. The main changes are:

  • Adds osa manifest for release-free JSON output.
  • Uses one typed model for manifest and deploy serialization.
  • Separates authored component settings from release artifacts.
  • Loads module- and object-form convention entry points.
  • Adds typed schema field definitions and updates tests.

Confidence Score: 5/5

This looks safe to merge.

The hook and ingester release paths now use separate key spaces. Object-form entry points load through the supported entry-point API. No blocking issues found in the updated code.

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex produced a proof for the posted P1 finding.
  • T-Rex ran the requested contract validation to verify the finding, but its local artifact references were not uploaded.

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
osa/cli/deploy.py Adds the shared typed manifest model and separates hook releases from the ingester release.
osa/cli/main.py Adds the manifest command and loads convention entry points through EntryPoint.load().
osa/types/schema.py Introduces typed field definitions for manifest and deploy serialization.

Comments Outside Diff (1)

  1. General comment

    P1 Optional ingester RuntimeConfig fields leak null into manifest and deploy serialization

    • Bug
      • A separately installed convention whose Pydantic RuntimeConfig has an optional field defaulting to None produces "optional_token": null in osa manifest. Binding releases retains the same null in the deploy wire. This violates the stated recursive no-null contract even though the outer serialization uses exclude_none=True.
    • Cause
      • build_manifest first converts RuntimeConfig into a plain dictionary with RuntimeConfig().model_dump() without exclude_none=True. Once nested inside the component's dynamic config dictionary, the outer ConventionManifest.model_dump(exclude_none=True) does not remove dictionary values that are None.
    • Fix
      • Serialize RuntimeConfig with model_dump(exclude_none=True) when constructing the ingester component. If arbitrary nested config dictionaries are supported, also apply a recursive null-pruning policy at the serialization edge and add a test containing optional nested config values.

    T-Rex Ran code and verified through T-Rex

Reviews (2): Last reviewed commit: "fix: address PR review — release-map col..." | Re-trigger Greptile

Comment thread osa/cli/deploy.py
if built is not None:
image, digest = built
releases[conv.ingester_info.name] = ComponentRelease(
image=image, digest=digest, source_ref=source_ref

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Component Names Collide Across Kinds

When an ingester has the same name as a hook, this assignment overwrites the hook's entry in the shared releases map. bind_releases() then gives both components the ingester image and digest, whereas the previous separate hook and ingester maps could not collide.

Context Used: Be extra harsh on architectural and layout inconsi... (source)

Artifacts

Repro: focused executable pytest harness for the same-name component collision

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: verbose failing test output with the captured releases map, serialized payload, and assertion diff

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7650a1. bind_releases now takes hook releases (matched by name) and the single ingester release as separate arguments, so hooks and the ingester no longer share a name keyspace and can't collide. Added a regression test (test_same_name_hook_and_ingester_do_not_collide).

Comment thread osa/cli/deploy.py
Comment on lines +463 to +465
info = next((hi for hi in _hooks if hi.name == h.__name__), None)
if info is None:
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Duplicate Hook Names Select Wrong Metadata

The registry does not require hook names to be unique, but this lookup selects the first global HookInfo with the same function name. If two convention packages define hooks with that name, the later convention can receive the first hook's limits, cardinality, and output columns in both its manifest and deploy payload.

Context Used: Be extra harsh on architectural and layout inconsi... (source)

Artifacts

Repro: focused duplicate hook registration and manifest harness

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Stack trace captured during the T-Rex run

  • Keeps the raw stack trace available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, but pre-existing — the identical global-registry lookup is already on main's deploy(), so it's out of scope for this PR. Tracked separately in #14 (root cause: the flat global _hooks registry keyed only by name; fix direction is to resolve hooks within their owning convention).

Comment thread osa/types/schema.py
@classmethod
def to_field_definitions(cls) -> list[dict[str, Any]]:
"""Convert this schema's fields to server FieldDefinition dicts.
def to_field_definitions(cls) -> list[FieldDefinition]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Public Method Changes Element Type

to_field_definitions() previously returned dictionaries, but now returns FieldDefinition instances. Existing consumers that use field["name"], field.get(...), or pass the result directly to a JSON encoder will fail even though the deploy wire remains unchanged; keep the dictionary-returning method as a compatibility wrapper or add a separately named typed method.

Context Used: Be extra harsh on architectural and layout inconsi... (source)

Artifacts

Repro: executable consumer compatibility test harness

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: verbose test output showing three compatibility errors and the retained model dump shape

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional and won't change here. Returning a typed FieldDefinition (instead of a raw dict) is the point of this change — moving off untyped dicts at the edges. to_field_definitions() is an internal serialization helper with no known external callers, osa-py is pre-1.0, and the only in-repo caller (build_manifest) is updated; the deploy wire is unchanged. A dict-returning shim would re-introduce the smell we're removing, so we're accepting the typed-return break.

Comment thread osa/cli/main.py Outdated
ui = _ui(ctx)

for ep in importlib.metadata.entry_points(group="osa.conventions"):
importlib.import_module(ep.value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Object Entry Points Cannot Load

A convention entry point using the valid package.module:register form reaches this command, but import_module() treats the full value as a module name and raises before emitting JSON. Load the entry point through its EntryPoint object so object-style registrations can be resolved.

Context Used: Be extra harsh on architectural and layout inconsi... (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7650a1. Switched to ep.load() (which resolves both module- and object-form entry points) at all three call sites — manifest, deploy, and test.

- bind_releases: bind hook releases (by name) and the single ingester
  release separately, so a hook and the ingester sharing a name can no
  longer collide in a shared keyspace (adds a regression test).
- entry-point discovery: use ep.load() instead of import_module(ep.value)
  so object-form entry points (package.module:attr) also load, across the
  manifest/deploy/test commands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rorybyrne
rorybyrne merged commit 304b787 into main Jul 22, 2026
4 checks passed
@rorybyrne
rorybyrne deleted the feat/manifest-command-and-contract branch July 22, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant