Skip to content

Commit 773764f

Browse files
author
bgagent
committed
feat(registry): design + storage + publish/resolve API (#246)
PR 1 of 3 for the central agent asset registry (ADR-018). Purely additive infrastructure — nothing in the orchestrator or agent reads from the registry yet; that follows in PR 2 (orchestrator + agent MCP E2E) and PR 3 (Cedar modules + skills). Substrate: DynamoDB + S3 (ADR-018 fallback option 2). The four fallback conditions already select it at authoring time — AgentCore Registry is in public preview and hard-migrates namespaces on 2026-08-06. The RegistryClient seam keeps the AgentCore path open as a later swap. - docs/design/REGISTRY.md: MVP asset kinds, DDB/S3 schema, API contract, resolution semantics, upstream-registry/federation stance (out of MVP scope). - Grammar: extend _REGISTRY_REF to registry://kind/namespace/name@constraint (snake_case kinds, mandatory semver pin). New contracts/registry-resolution parity corpus proves the Python regex and TS parseRef agree byte-for-byte. - Shared types (canonical status='approved'; 'active' is not a code value), mirrored CDK<->CLI. - registry-resolver.ts: parseRef/resolveRef/resolveAll, fail-closed, semver ranked in code (DDB sorts versions lexicographically). - RegistryAssetsTable (pk/sk + kind-index GSI) and RegistryArtifactsBucket (versioned, TLS-only, SSE) constructs, wired into AgentStack. - Publish/resolve/list/show handlers: immutability 409, descriptor validation, Cognito RegistryPublisher/Approver gating; /registry routes on TaskApi with least-privilege IAM grants. Depends on #548 (ADR-018) — the REGISTRY.md link to the ADR resolves once that PR merges to main; docs link-check stays red until then by design.
1 parent 0132d6d commit 773764f

50 files changed

Lines changed: 3465 additions & 15 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

agent/src/workflow/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
gate_status,
4343
run_workflow,
4444
)
45-
from .validator import assert_valid, validate_workflow
45+
from .validator import assert_valid, is_registry_ref, validate_workflow
4646

4747
__all__ = [
4848
"STEP_HANDLERS",
@@ -62,6 +62,7 @@
6262
"WorkflowValidationError",
6363
"assert_valid",
6464
"gate_status",
65+
"is_registry_ref",
6566
"load_workflow",
6667
"load_workflow_file",
6768
"policy_principal_for",

agent/src/workflow/validator.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,23 @@
4646

4747
# Built-in (Phase 1-3) policy modules / MCP servers. Registry refs (registry://)
4848
# are accepted syntactically now and resolved against #246 in Phase 4 (rule 8).
49+
#
50+
# _REGISTRY_REF is the #246 asset grammar (docs/design/REGISTRY.md §6, ADR-018):
51+
# registry://<kind>/<namespace>/<name>@<constraint>
52+
# The kind segment is snake_case (mcp_server, cedar_policy_module) and the
53+
# @<constraint> pin is MANDATORY (fail-closed, ADR-018 sub-decision 6) — exact,
54+
# caret, or tilde semver only; floating (*, latest, >=) is rejected. This regex
55+
# MUST stay byte-for-byte identical to the TypeScript ``parseRef``
56+
# (cdk/src/handlers/shared/registry-resolver.ts); the ``contracts/registry-resolution/``
57+
# corpus is the agreement both sides reproduce.
4958
_BUILTIN_REF = re.compile(r"^builtin/[a-z][a-z0-9_]*$")
50-
_REGISTRY_REF = re.compile(r"^registry://[a-z][a-z0-9-]*/[a-z0-9][a-z0-9./-]*$")
59+
_REGISTRY_REF = re.compile(
60+
r"^registry://"
61+
r"[a-z][a-z0-9_]*/" # kind (snake_case)
62+
r"[a-z][a-z0-9-]*/" # namespace
63+
r"[a-z0-9][a-z0-9._-]*" # name
64+
r"@[\^~]?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$" # @constraint (exact/caret/tilde)
65+
)
5166

5267
# Mutating built-in tools — forbidden under the read-only tier (rule 6) and when
5368
# read_only:true (rule 4, shape half is in the schema).
@@ -113,6 +128,16 @@ def _ref_resolves(ref: str) -> bool:
113128
return bool(_BUILTIN_REF.match(ref) or _REGISTRY_REF.match(ref))
114129

115130

131+
def is_registry_ref(ref: str) -> bool:
132+
"""True iff ``ref`` matches the #246 registry URI grammar (REGISTRY.md §6).
133+
134+
Public entry point for the ``contracts/registry-resolution/`` parity corpus:
135+
this MUST agree byte-for-byte with the TypeScript ``parseRef``. Does not
136+
match ``builtin/*`` — grammar only, not resolvability.
137+
"""
138+
return bool(_REGISTRY_REF.match(ref))
139+
140+
116141
# --- the rules ---------------------------------------------------------------
117142
# Each returns a list of human-readable messages (empty == passed). Rule numbers
118143
# match WORKFLOWS.md §"Validation rules". Rules 3/4/7 also have a schema half;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Registry URI grammar parity — agent side (#246).
2+
3+
Loads every ``contracts/registry-resolution/grammar-*.json`` fixture and asserts
4+
that ``workflow.is_registry_ref(ref)`` agrees with the fixture's ``expected.valid``.
5+
This is the golden contract that the TypeScript ``parseRef``
6+
(cdk/src/handlers/shared/registry-resolver.ts) must also reproduce — mirroring
7+
the cross-engine pattern in ``test_cedar_parity.py`` and the workflow-validation
8+
corpus.
9+
10+
If the regex and a fixture disagree, CI fails before the change ships: either
11+
the grammar regressed, or the fixture must be updated as a recorded decision.
12+
See ``contracts/registry-resolution/README.md`` and REGISTRY.md §6.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import json
18+
import os
19+
from pathlib import Path
20+
21+
import pytest
22+
23+
from workflow import is_registry_ref
24+
25+
_FIXTURE_DIR = (
26+
Path(os.path.dirname(__file__)) / ".." / ".." / "contracts" / "registry-resolution"
27+
).resolve()
28+
29+
30+
def _load_grammar_fixtures() -> list[dict]:
31+
assert _FIXTURE_DIR.is_dir(), (
32+
f"expected fixture dir at {_FIXTURE_DIR}; "
33+
"see contracts/registry-resolution/README.md"
34+
)
35+
fixtures = []
36+
for path in sorted(_FIXTURE_DIR.glob("grammar-*.json")):
37+
fixture = json.loads(path.read_text(encoding="utf-8"))
38+
for required in ("name", "ref", "expected"):
39+
if required not in fixture:
40+
raise AssertionError(f"{path.name}: missing required field {required!r}")
41+
if "valid" not in fixture["expected"]:
42+
raise AssertionError(f"{path.name}: expected missing 'valid'")
43+
fixtures.append(fixture)
44+
assert fixtures, f"no grammar-*.json fixtures under {_FIXTURE_DIR}"
45+
return fixtures
46+
47+
48+
_FIXTURES = _load_grammar_fixtures()
49+
50+
51+
@pytest.mark.parametrize("fixture", _FIXTURES, ids=[f["name"] for f in _FIXTURES])
52+
def test_grammar_matches_fixture(fixture: dict) -> None:
53+
observed = is_registry_ref(fixture["ref"])
54+
expected = fixture["expected"]["valid"]
55+
assert observed == expected, (
56+
f"fixture {fixture['name']!r}: grammar drift — is_registry_ref({fixture['ref']!r}) "
57+
f"returned {observed}, fixture expects {expected}"
58+
)
59+
60+
61+
def test_corpus_covers_valid_and_invalid() -> None:
62+
"""The corpus must exercise both accept and reject paths."""
63+
valids = [f for f in _FIXTURES if f["expected"]["valid"]]
64+
invalids = [f for f in _FIXTURES if not f["expected"]["valid"]]
65+
assert valids, "grammar corpus must include at least one valid ref"
66+
assert invalids, "grammar corpus must include at least one invalid ref"

agent/tests/test_workflow_validator.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ def test_rule6_read_only_tier_rejects_elevated_fields(self):
150150
w["agent_config"]["tier"] = "read-only"
151151
w["agent_config"]["allowed_tools"] = ["Bash", "Read"]
152152
w["agent_config"]["cedar_policy_modules"] = ["builtin/hard_deny"]
153-
w["agent_config"]["skills"] = ["registry://skill/x-v1"]
153+
w["agent_config"]["skills"] = ["registry://skill/acme/x@^1.0.0"]
154154
w["steps"] = [
155155
{"kind": "clone_repo"},
156156
{"kind": "hydrate_context"},
@@ -165,7 +165,17 @@ def test_rule6_read_only_tier_rejects_elevated_fields(self):
165165
"ref,bad",
166166
[
167167
("builtin/soft_deny", False),
168-
("registry://cedar/custom-v1", False),
168+
# #246 grammar (REGISTRY.md §6): registry://kind/namespace/name@constraint
169+
("registry://cedar_policy_module/acme/custom@^1.0.0", False),
170+
("registry://cedar_policy_module/acme/custom@1.0.0", False),
171+
("registry://cedar_policy_module/acme/custom@~1.2.3", False),
172+
# legacy 2-segment form is no longer valid grammar (no namespace, no pin)
173+
("registry://cedar/custom-v1", True),
174+
# unpinned 3-segment is rejected — pins are mandatory (fail-closed)
175+
("registry://cedar_policy_module/acme/custom", True),
176+
# floating constraints rejected
177+
("registry://cedar_policy_module/acme/custom@latest", True),
178+
("registry://cedar_policy_module/acme/custom@>=1.0.0", True),
169179
("http://evil", True),
170180
("soft_deny", True),
171181
],

cdk/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"constructs": "^10.6.0",
3737
"js-yaml": "^4.1.1",
3838
"pdf-parse": "^2.4.5",
39+
"semver": "^7.8.5",
3940
"ulid": "^3.0.2",
4041
"ws": "^8.21.0"
4142
},
@@ -49,6 +50,7 @@
4950
"@types/js-yaml": "^4.0.9",
5051
"@types/node": "^26",
5152
"@types/pdf-parse": "^1.1.5",
53+
"@types/semver": "^7.7.1",
5254
"@types/ws": "^8.18.1",
5355
"@typescript-eslint/eslint-plugin": "^8",
5456
"@typescript-eslint/parser": "^8",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { Duration, RemovalPolicy } from 'aws-cdk-lib';
21+
import * as s3 from 'aws-cdk-lib/aws-s3';
22+
import { Construct } from 'constructs';
23+
24+
/**
25+
* How long a noncurrent (superseded) object version is retained before expiry.
26+
* The registry is immutable-per-version, so overwrites at a live key should not
27+
* happen; this rule reaps versions left behind by a ``removed`` tombstone or an
28+
* operational re-put, keeping storage bounded without touching current objects.
29+
*/
30+
export const REGISTRY_ARTIFACT_NONCURRENT_TTL_DAYS = 90;
31+
32+
/**
33+
* Properties for the RegistryArtifactsBucket construct.
34+
*/
35+
export interface RegistryArtifactsBucketProps {
36+
/**
37+
* Removal policy for the bucket. Registry artifacts are referenced by pinned
38+
* task records for audit/reproducibility, so production should RETAIN; the
39+
* construct default stays DESTROY for the sample stack's dev-first posture.
40+
* @default RemovalPolicy.DESTROY
41+
*/
42+
readonly removalPolicy?: RemovalPolicy;
43+
44+
/**
45+
* Whether to auto-delete objects when the bucket is removed (so ``cdk
46+
* destroy`` does not need a manual bucket-empty first). Mirrors the other
47+
* sample-stack buckets.
48+
* @default true
49+
*/
50+
readonly autoDeleteObjects?: boolean;
51+
}
52+
53+
/**
54+
* S3 bucket for agent asset registry artifact bytes (#246; see
55+
* docs/design/REGISTRY.md §3.4 and ADR-018).
56+
*
57+
* Stores the artifact for each published asset version — the MCP server config
58+
* JSON, Cedar policy text, or skill prompt fragment — under the key
59+
* ``{kind}/{namespace}/{name}/{version}/artifact``. Metadata lives in
60+
* ``RegistryAssetsTable``; only the bytes live here.
61+
*
62+
* Differs from the ephemeral ``EcsPayloadBucket``: artifacts are durable
63+
* (referenced by pinned task records for audit and reproducibility), so
64+
* **versioning is ON** and there is no whole-object TTL — only a noncurrent-
65+
* version expiry to bound storage. Immutability of a published
66+
* ``(kind, namespace, name, version)`` is enforced at the DynamoDB write (409
67+
* on collision), not by S3 object-lock, so a ``removed`` status can tombstone a
68+
* record without a compliance-grade byte delete.
69+
*
70+
* Security / hygiene (parity with the other sample buckets):
71+
* - ``blockPublicAccess: BLOCK_ALL`` + ``enforceSSL: true`` — no public read,
72+
* TLS-only transport.
73+
* - ``encryption: S3_MANAGED`` — server-side encryption at rest.
74+
* - ``versioned: true`` — retain superseded bytes for audit.
75+
*/
76+
export class RegistryArtifactsBucket extends Construct {
77+
/** The underlying S3 bucket. */
78+
public readonly bucket: s3.Bucket;
79+
80+
constructor(scope: Construct, id: string, props: RegistryArtifactsBucketProps = {}) {
81+
super(scope, id);
82+
83+
this.bucket = new s3.Bucket(this, 'Bucket', {
84+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
85+
encryption: s3.BucketEncryption.S3_MANAGED,
86+
enforceSSL: true,
87+
versioned: true,
88+
lifecycleRules: [
89+
{
90+
id: 'registry-artifact-noncurrent-expiry',
91+
enabled: true,
92+
noncurrentVersionExpiration: Duration.days(REGISTRY_ARTIFACT_NONCURRENT_TTL_DAYS),
93+
// Reap incomplete multipart uploads after 1 day so stale upload parts
94+
// (which object/version expiry does not cover) do not linger.
95+
abortIncompleteMultipartUploadAfter: Duration.days(1),
96+
},
97+
],
98+
removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY,
99+
autoDeleteObjects: props.autoDeleteObjects ?? true,
100+
});
101+
}
102+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { RemovalPolicy } from 'aws-cdk-lib';
21+
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
22+
import { Construct } from 'constructs';
23+
24+
/** GSI name for listing every asset of a given kind (REGISTRY.md §3.1). */
25+
export const REGISTRY_KIND_INDEX = 'kind-index';
26+
27+
/**
28+
* Properties for the RegistryAssetsTable construct.
29+
*/
30+
export interface RegistryAssetsTableProps {
31+
/**
32+
* Optional table name override.
33+
* @default - auto-generated by CloudFormation
34+
*/
35+
readonly tableName?: string;
36+
37+
/**
38+
* Removal policy for the table. Registry records are the source of truth for
39+
* what a task may load and are immutable-per-version, so production
40+
* deployments should RETAIN; the construct default stays DESTROY to match the
41+
* rest of the sample stack's dev-first posture.
42+
* @default RemovalPolicy.DESTROY
43+
*/
44+
readonly removalPolicy?: RemovalPolicy;
45+
46+
/**
47+
* Whether to enable point-in-time recovery.
48+
* @default true
49+
*/
50+
readonly pointInTimeRecovery?: boolean;
51+
}
52+
53+
/**
54+
* DynamoDB table backing the agent asset registry (#246; see
55+
* docs/design/REGISTRY.md §3.1 and ADR-018).
56+
*
57+
* Schema:
58+
* - ``pk`` (partition) = ``{kind}#{namespace}/{name}`` — groups every version
59+
* of one asset under a single partition.
60+
* - ``sk`` (sort) = the semver ``version`` string.
61+
*
62+
* A single ``Query`` on ``pk`` returns all versions of an asset (show + resolve;
63+
* resolution ranks by parsed semver in code, since DynamoDB sorts the version
64+
* sort key lexicographically). The ``kind-index`` GSI (partition ``kind``, sort
65+
* ``pk``) powers ``GET /registry/assets?kind=...`` without a table scan.
66+
*
67+
* There is no TTL: registry records are immutable-per-version and audited; a
68+
* ``removed`` status tombstones a record rather than deleting the row (a task
69+
* pinned to it fails closed with ``REMOVED``).
70+
*/
71+
export class RegistryAssetsTable extends Construct {
72+
/**
73+
* The underlying DynamoDB table. Use this to grant access or read the table name.
74+
*/
75+
public readonly table: dynamodb.Table;
76+
77+
constructor(scope: Construct, id: string, props: RegistryAssetsTableProps = {}) {
78+
super(scope, id);
79+
80+
this.table = new dynamodb.Table(this, 'Table', {
81+
tableName: props.tableName,
82+
partitionKey: {
83+
name: 'pk',
84+
type: dynamodb.AttributeType.STRING,
85+
},
86+
sortKey: {
87+
name: 'sk',
88+
type: dynamodb.AttributeType.STRING,
89+
},
90+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
91+
pointInTimeRecoverySpecification: {
92+
pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true,
93+
},
94+
removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY,
95+
});
96+
97+
this.table.addGlobalSecondaryIndex({
98+
indexName: REGISTRY_KIND_INDEX,
99+
partitionKey: {
100+
name: 'kind',
101+
type: dynamodb.AttributeType.STRING,
102+
},
103+
sortKey: {
104+
name: 'pk',
105+
type: dynamodb.AttributeType.STRING,
106+
},
107+
// The list endpoint needs kind, namespace, name, version, and status to
108+
// render a summary — a KEYS_ONLY projection would force a second read per
109+
// row, so project everything.
110+
projectionType: dynamodb.ProjectionType.ALL,
111+
});
112+
}
113+
}

0 commit comments

Comments
 (0)