AWS Marketplace Metering Overview Complete lifecycle management for AWS Marketplace usage-based metering: - Setup (SaaS PAYG) — deploy a new serverless metering integration (SAM, ResolveCustomer, BatchMeterUsage, EventBridge). - Query — find, inspect, debug, analyze existing metering submissions (CloudTrail Event History ≤90 days; Seller Reports for billed history). Scope is SaaS Usage-based (PAYG). SaaS Contract, Contract with Consumption Pricing (CCP), and AMI/Container are out of scope — redirect to public AWS docs. Recommended: the AWS MCP server provides sandboxed execution and audit log…

\n ConstraintDescription: >-\n StageName is mandatory and must be a non-empty API Gateway stage name\n (letters, digits, '-' or '_'), e.g. 'v1', 'live', or 'prod'.\n Description: >-\n API Gateway stage name for the registration endpoint. TODO(seller): this is a\n MANDATORY value you must supply (e.g. `v1`, `live`, or `prod` — your choice). There\n is NO default and no fallback to `prod`: the parameter has no Default, so a direct\n `sam deploy` with no override fails fast at the template level (and AllowedPattern\n rejects an empty/invalid value) rather than creating a malformed empty stage.\n deploy.sh performs the same non-empty check before deploying.\n WebAclArn:\n Type: String\n Default: ''\n Description: >-\n Optional override: ARN of a seller-supplied AWS WAFv2 WebACL (REGIONAL scope) to\n associate with the registration API stage instead of the baseline WebACL this stack\n creates. Leave empty to use the built-in baseline WebACL (rate-based + common-exploit\n rules) that the stack creates and attaches by default.\n LogsKmsKeyArn:\n Type: String\n Default: ''\n Description: >-\n Optional KMS key ARN to encrypt the Lambda + API access log groups (logs contain\n customer AWS account IDs / license ARNs). Leave empty for CloudWatch\n default encryption. The key policy MUST allow logs.\u003cregion>.amazonaws.com.\n LogRetentionInDays:\n Type: Number\n Default: 90\n Description: Retention (days) for the Register/Meter Lambda and API access log groups.\n MeteringLockHours:\n Type: Number\n Default: 1\n MinValue: 1\n MaxValue: 20\n Description: >-\n How many hours a metering hour stays OPEN for late usage before it is\n aggregated and submitted. The discoverer's meter mode no-ops for hour offsets below\n this value, so an hour is first submitted at age MeteringLockHours. Default 1 (submit\n a fully-complete hour: at 12:20 only 11:00-11:59:59 is processed). Bounded 1..20: an\n hour is first submitted at age MeteringLockHours, leaving 24 - MeteringLockHours hours\n inside the 24h billable window before age-out, so the cap of 20 leaves >=4h of\n margin — room for >=3 hourly submission retries of a transient failure AND for the\n seller to inspect + re-drive a client-side rejection before the hour ages out at 24h.\n TODO(seller): ask the seller how long to keep an hour open for late events.\n UsageTableTtlDays:\n Type: Number\n Default: 365\n MinValue: 0\n MaxValue: 3650\n Description: >-\n TTL retention (in DAYS) for RAW usage-table rows, via a numeric `ttl` epoch-seconds\n attribute. The raw usage table is the HIGH-VOLUME table (one row per second per group),\n so a TTL is RECOMMENDED here to control storage cost. Set 0 to DISABLE TTL entirely\n (the TimeToLiveSpecification is then omitted via a CloudFormation Condition).\n CORRECTNESS FLOOR: when ENABLED the value must exceed MeteringLockHours + the 24h\n age-out window with margin, so the accepted set is 0 (disabled) OR >= 2 days — a\n value of 1 is rejected by deploy.sh (CloudFormation MinValue cannot express a disjoint\n \"0 or >= 2\" range, so MinValue is 0 here and the >= 2 floor is enforced in deploy.sh).\n IMPORTANT: the seller's usage WRITER sets the `ttl` attribute (writer logic is out of\n scope); enabling TTL here only makes DynamoDB honor a `ttl` the writer populates. If\n the writer does not set `ttl`, no raw row expires (TTL is a no-op) — that is safe, just\n not cost-optimal. TODO(seller): confirm the retention that satisfies your audit needs.\n AggregatedUsageTableTtlDays:\n Type: Number\n Default: 0\n MinValue: 0\n MaxValue: 3650\n Description: >-\n TTL retention (in DAYS) for the AGGREGATED usage table, via a numeric `ttl`\n epoch-seconds attribute the submitter sets on FINALIZED rows. Defaults to 0 (DISABLED\n = retain), which is RECOMMENDED: this table is one small row per group per hour (not\n high-volume) and is a billing AUDIT trail (MeteringRecordId + status), so keeping it\n aids auditing/debugging. Set a LARGE value only if you want eventual pruning; the\n submitter sets `ttl` ONLY on rows that reached a terminal Success state, never on\n pending/failed rows. TODO(seller): confirm whether aggregated-usage pruning is wanted.\n CreateDashboard:\n Type: String\n Default: 'true'\n AllowedValues: ['true', 'false']\n Description: >-\n Whether to create the per-product CloudWatch HEALTH dashboard for the metering\n pipeline (default true). The dashboard gives complete pipeline visibility in one place\n (per-Lambda errors/throttles/invocations/duration, queue + DLQ depths + oldest-message\n age, and the granular EMF error/status metrics) without drilling into individual\n alarms. It is health-only (no business metrics). Set 'false' to skip it; you can also\n delete or customize the dashboard after deployment.\n AllowedRegistrationFields:\n Type: String\n Default: ''\n Description: >-\n Comma-separated allowlist of registration-form fields the Register Lambda may\n persist. The public endpoint persists ONLY these fields, each\n length-bounded. Leave empty to persist no custom fields. Example:\n \"company_name,email,team_size\".\n PromotedProfileFields:\n Type: String\n Default: ''\n Description: >-\n Comma-separated SUBSET of AllowedRegistrationFields to PROMOTE to top-level\n attributes on the customer-profile table so a GSI can look profiles up by that\n field (e.g. \"email\"). Each promoted field is written both into the registrationData\n map AND as a top-level attribute; define a matching GSI in the CustomerProfileTable\n TODO(seller) block. Leave empty for none. Example: \"email,company_name\".\n PermissionsBoundaryName:\n Type: String\n Default: awsmp-metering-boundary\n Description: >-\n Name of the IAM permissions-boundary policy applied to the SAM-generated Lambda\n execution roles. This MUST match the boundary the deployer role's iam:CreateRole\n condition requires, and the boundary policy MUST already exist in the account\n before deployment (see references/iam-credentials.md). The Lambda roles are\n auto-generated by SAM from the inline Policies below; without this boundary,\n role creation is denied by the deployer role.\n\nConditions:\n HasAlertsTopic: !Not [!Equals [!Ref AlertsTopicArn, '']]\n # Full pipeline (raw table + discoverer/aggregator/cleanup + work/cleanup queues) vs\n # direct-submit (seller writes finalized records straight to aggregated_usage).\n IsFullPipeline: !Equals [!Ref DeploymentMode, full]\n IsDirectSubmit: !Equals [!Ref DeploymentMode, direct-submit]\n # Non-production sandbox stage: submitter dry-runs (no real BatchMeterUsage) and the test\n # event publisher is created. live = production (real billing).\n IsDryRun: !Equals [!Ref MeteringMode, dry-run]\n # TTL is included only when the retention (days) parameter is > 0 (0 = disabled).\n UsageTableTtlEnabled: !And [!Equals [!Ref DeploymentMode, full], !Not [!Equals [!Ref UsageTableTtlDays, 0]]]\n AggregatedUsageTableTtlEnabled: !Not [!Equals [!Ref AggregatedUsageTableTtlDays, 0]]\n CreateHealthDashboard: !Equals [!Ref CreateDashboard, 'true']\n CreateFullDashboard: !And [!Equals [!Ref CreateDashboard, 'true'], !Equals [!Ref DeploymentMode, full]]\n CreateDirectDashboard: !And [!Equals [!Ref CreateDashboard, 'true'], !Equals [!Ref DeploymentMode, direct-submit]]\n HasWebAcl: !Not [!Equals [!Ref WebAclArn, '']]\n # When no seller WebACL ARN is provided, create + attach the built-in baseline WebACL.\n UseBaselineWebAcl: !Equals [!Ref WebAclArn, '']\n HasLogsKmsKey: !Not [!Equals [!Ref LogsKmsKeyArn, '']]\n\nGlobals:\n Function:\n Runtime: python3.12\n Timeout: 30\n # Apply the permissions boundary to the SAM-auto-generated Lambda execution roles so\n # iam:CreateRole succeeds under the deployer role (whose iam:CreateRole is gated on a\n # matching iam:PermissionsBoundary). The boundary policy must exist before deployment.\n PermissionsBoundary: !Sub arn:aws:iam::${AWS::AccountId}:policy/${PermissionsBoundaryName}\n Environment:\n Variables:\n PRODUCT_CODE: !Ref ProductCode\n SUBSCRIBERS_TABLE: !Ref SubscribersTableName\n SUBSCRIBERS_TABLE_REGION: us-east-1\n AGGREGATED_USAGE_TABLE: !Ref AggregatedUsageTable\n\nResources:\n # In-region customer-profile table (buyer PII / registration data). Present in BOTH\n # deployment modes (registration happens regardless of full vs direct-submit). Keeps buyer\n # PII in THIS Region — the register Lambda writes the allowlisted registration fields here as\n # a `registrationData` map (+ any promoted top-level fields), never to the us-east-1\n # subscribers table (which stays PII-free). Keyed to match the subscriber identity.\n CustomerProfileTable:\n Type: AWS::DynamoDB::Table\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n Properties:\n TableName: !Sub ${StackPrefix}-customer-profile\n BillingMode: PAY_PER_REQUEST\n PointInTimeRecoverySpecification:\n PointInTimeRecoveryEnabled: true\n SSESpecification:\n SSEEnabled: true\n AttributeDefinitions:\n - AttributeName: licenseArn\n AttributeType: S\n - AttributeName: customerAWSAccountId\n AttributeType: S\n # TODO(seller): for each PROMOTED registration field (PromotedProfileFields), add its\n # AttributeDefinition here and a matching GSI below. Example for an `email` field:\n # - AttributeName: email\n # AttributeType: S\n KeySchema:\n - AttributeName: licenseArn\n KeyType: HASH\n - AttributeName: customerAWSAccountId\n KeyType: RANGE\n # TODO(seller): define one GSI per promoted registration field so profiles can be looked\n # up by that field WITHOUT a Scan (the register Lambda writes each promoted field as a\n # top-level attribute in addition to the registrationData map). Example:\n # GlobalSecondaryIndexes:\n # - IndexName: email-index\n # KeySchema:\n # - AttributeName: email\n # KeyType: HASH\n # Projection:\n # ProjectionType: ALL\n Tags:\n - Key: ManagedBy\n Value: marketplace-metering-skill\n\n UsageTable:\n Type: AWS::DynamoDB::Table\n Condition: IsFullPipeline\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n Properties:\n TableName: !Sub ${StackPrefix}-usage\n BillingMode: PAY_PER_REQUEST\n PointInTimeRecoverySpecification:\n PointInTimeRecoveryEnabled: true\n SSESpecification:\n SSEEnabled: true\n # TTL (seller-configurable via UsageTableTtlDays; RECOMMENDED on this high-volume\n # table to control cost). Only DynamoDB-honored; the seller's WRITER must set the\n # numeric `ttl` epoch-seconds attribute (writer logic is out of scope), and MUST set\n # it beyond MeteringLockHours + the 24h billable window so a row is never deleted\n # before it is metered. Omitted entirely when UsageTableTtlDays = 0.\n TimeToLiveSpecification: !If\n - UsageTableTtlEnabled\n - AttributeName: ttl\n Enabled: true\n - !Ref 'AWS::NoValue'\n Tags:\n - Key: ManagedBy\n Value: marketplace-metering-skill\n # Usage table key schema: licenseArn (HASH) + customerAWSAccountId_dimension_timestamp\n # (RANGE). The RANGE attribute value is the '#'-delimited composite\n # \"{customerAWSAccountId}#{dimension}#{timestamp}\" where timestamp is an exact\n # SECOND-precision UTC value \"YYYY-MM-DDTHH:MM:SS\" (whole seconds, NOT millisecond/\n # fractional and NOT hour-truncated). Second precision lets multiple usage rows exist\n # for the same (licenseArn, customerAWSAccountId, dimension, hour) — each distinct\n # second is a distinct row (bounded at \u003c=3600 rows/hour). The aggregator validates the\n # sort key at the fold and rejects a bad one client-side with EXACTLY ONE reason code\n # per condition, checked in order: MalformedSortKey (empty or fewer than the 3\n # '#'-segments) -> SortKeyMismatch (account or dimension segment != the row's own\n # attribute) -> MalformedTimestamp (timestamp segment not exact whole-second\n # YYYY-MM-DDTHH:MM:SS, incl. a millisecond/fractional or hour-truncated suffix).\n # Write whole seconds only.\n # customerAWSAccountId, dimension, and timestamp are ALSO stored as separate\n # top-level attributes on each item so the pipeline reads them without parsing\n # the sort key (in particular customerAWSAccountId is on every row, so a CA\n # UsageRecord is built without a subscriber-table lookup). The aggregator reads a\n # group via begins_with(sortKey, \"{customerAWSAccountId}#{dimension}#{hourPrefix}\")\n # and aggregates the hour's rows into one UsageRecord.\n # TODO(seller): your usage-writer (ingestion) must populate licenseArn, the composite\n # sort key (whole-second UTC timestamp), and the customerAWSAccountId / dimension /\n # timestamp / quantity attrs, plus set meteringPending to the \"%Y-%m-%dT%H\" hour bucket\n # (matching the row's own timestamp hour) while the row awaits metering. Additional\n # seller-defined attributes (e.g. eventId) are preserved. RECOMMENDED: stamp ISO-8601\n # UTC createdAt (once, at insert) + updatedAt (on any rewrite) audit timestamps on each\n # row; the pipeline stamps updatedAt when it finalizes a row.\n AttributeDefinitions:\n - AttributeName: licenseArn\n AttributeType: S\n - AttributeName: customerAWSAccountId_dimension_timestamp\n AttributeType: S\n - AttributeName: meteringPending\n AttributeType: S\n KeySchema:\n - AttributeName: licenseArn\n KeyType: HASH\n - AttributeName: customerAWSAccountId_dimension_timestamp\n KeyType: RANGE\n GlobalSecondaryIndexes:\n # metering_pending GSI: lets the meter Lambda find usage awaiting metering\n # WITHOUT depending on the subscribers/registration table. Sellers who obtain\n # the LicenseArn via EventBridge or SDDS (bypassing ResolveCustomer) still get\n # metered. Set meteringPending=\"\u003chourBucket>\" when writing usage; the meter\n # Lambda queries this GSI, meters, then clears the attribute (removing the item\n # from the sparse index).\n - IndexName: metering_pending\n KeySchema:\n - AttributeName: meteringPending\n KeyType: HASH\n - AttributeName: licenseArn\n KeyType: RANGE\n Projection:\n ProjectionType: ALL\n\n # Aggregated-usage table: the submission source of truth for the decoupled\n # metering pipeline. The Aggregator folds all raw usage rows of a\n # (licenseArn, customerAWSAccountId, dimension, hour) group into ONE record here via a\n # CONDITIONAL PutItem (attribute_not_exists(licenseArn)) — the idempotency commit point.\n # The Submitter reads pending records via the metering_pending GSI, calls\n # BatchMeterUsage, and writes meteringRecordId/meteringStatus + REMOVEs meteringPending\n # ON THIS table (never the raw usage table). TTL is seller-configurable via\n # AggregatedUsageTableTtlDays but DEFAULTS OFF (0): this is a small billing audit trail\n # (MeteringRecordId + status), so retaining it aids auditing/debugging. When enabled, the\n # submitter sets the `ttl` epoch ONLY on finalized Success rows.\n AggregatedUsageTable:\n Type: AWS::DynamoDB::Table\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n Properties:\n TableName: !Sub ${StackPrefix}-aggregated-usage\n BillingMode: PAY_PER_REQUEST\n PointInTimeRecoverySpecification:\n PointInTimeRecoveryEnabled: true\n SSESpecification:\n SSEEnabled: true\n TimeToLiveSpecification: !If\n - AggregatedUsageTableTtlEnabled\n - AttributeName: ttl\n Enabled: true\n - !Ref 'AWS::NoValue'\n Tags:\n - Key: ManagedBy\n Value: marketplace-metering-skill\n # PK licenseArn (HASH) + account#dimension#hour (RANGE). meteringPending (hour\n # bucket) drives the submitter's sparse-GSI discovery, mirroring the raw usage table.\n AttributeDefinitions:\n - AttributeName: licenseArn\n AttributeType: S\n - AttributeName: account_dimension_hour\n AttributeType: S\n - AttributeName: meteringPending\n AttributeType: S\n KeySchema:\n - AttributeName: licenseArn\n KeyType: HASH\n - AttributeName: account_dimension_hour\n KeyType: RANGE\n GlobalSecondaryIndexes:\n - IndexName: metering_pending\n KeySchema:\n - AttributeName: meteringPending\n KeyType: HASH\n - AttributeName: licenseArn\n KeyType: RANGE\n Projection:\n ProjectionType: ALL\n\n # ── SQS: decoupled pipeline queues ─────────────────────────────────────\n # Work queue (discoverer -> aggregator): one message per pending group. Standard queue\n # (idempotency is enforced downstream by the aggregator's conditional PutItem).\n MeteringWorkQueue:\n Type: AWS::SQS::Queue\n Condition: IsFullPipeline\n Properties:\n QueueName: !Sub ${StackPrefix}-metering-work\n SqsManagedSseEnabled: true\n VisibilityTimeout: 360 # >= 6x aggregator Timeout (60s) per SQS ESM guidance\n RedrivePolicy:\n deadLetterTargetArn: !GetAtt MeteringWorkDLQ.Arn\n maxReceiveCount: 5\n Tags:\n - Key: ManagedBy\n Value: marketplace-metering-skill\n\n MeteringWorkDLQ:\n Type: AWS::SQS::Queue\n Condition: IsFullPipeline\n Properties:\n QueueName: !Sub ${StackPrefix}-metering-work-dlq\n SqsManagedSseEnabled: true\n MessageRetentionPeriod: 1209600 # 14 days\n Tags:\n - Key: ManagedBy\n Value: marketplace-metering-skill\n\n # Dedicated deprovision-work queue (flush-deprovisioning sweep -> aggregator). Separate from\n # the regular work queue so a deprovisioning backlog is isolated from — and never queued\n # behind — the regular hourly backlog; feeds the SAME aggregator function via its own ESM.\n DeprovisionWorkQueue:\n Type: AWS::SQS::Queue\n Condition: IsFullPipeline\n Properties:\n QueueName: !Sub ${StackPrefix}-deprovision-work\n SqsManagedSseEnabled: true\n VisibilityTimeout: 360 # >= 6x aggregator Timeout (60s) per SQS ESM guidance\n RedrivePolicy:\n deadLetterTargetArn: !GetAtt DeprovisionWorkDLQ.Arn\n maxReceiveCount: 5\n Tags:\n - Key: ManagedBy\n Value: marketplace-metering-skill\n\n DeprovisionWorkDLQ:\n Type: AWS::SQS::Queue\n Condition: IsFullPipeline\n Properties:\n QueueName: !Sub ${StackPrefix}-deprovision-work-dlq\n SqsManagedSseEnabled: true\n MessageRetentionPeriod: 1209600 # 14 days\n Tags:\n - Key: ManagedBy\n Value: marketplace-metering-skill\n\n # Cleanup queue (aggregator -> cleanup): each message carries \u003c=100 raw row keys whose\n # meteringPending must be cleared on the raw usage table.\n MeteringCleanupQueue:\n Type: AWS::SQS::Queue\n Condition: IsFullPipeline\n Properties:\n QueueName: !Sub ${StackPrefix}-metering-cleanup\n SqsManagedSseEnabled: true\n VisibilityTimeout: 720 # >= 6x cleanup Timeout (120s) per SQS ESM guidance\n RedrivePolicy:\n deadLetterTargetArn: !GetAtt MeteringCleanupDLQ.Arn\n maxReceiveCount: 5\n Tags:\n - Key: ManagedBy\n Value: marketplace-metering-skill\n\n MeteringCleanupDLQ:\n Type: AWS::SQS::Queue\n Condition: IsFullPipeline\n Properties:\n QueueName: !Sub ${StackPrefix}-metering-cleanup-dlq\n SqsManagedSseEnabled: true\n MessageRetentionPeriod: 1209600\n Tags:\n - Key: ManagedBy\n Value: marketplace-metering-skill\n\n RegistrationApi:\n Type: AWS::Serverless::Api\n Properties:\n Name: !Sub ${StackPrefix}-registration-api\n StageName: !Ref StageName\n Auth:\n DefaultAuthorizer: NONE\n # API Gateway access logging: all requests to the public registration\n # endpoint are auditable. Log group is KMS-encrypted + retention-bounded below.\n AccessLogSetting:\n DestinationArn: !GetAtt ApiAccessLogGroup.Arn\n Format: '{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"requestTime\":\"$context.requestTime\",\"httpMethod\":\"$context.httpMethod\",\"routeKey\":\"$context.routeKey\",\"status\":\"$context.status\",\"protocol\":\"$context.protocol\",\"responseLength\":\"$context.responseLength\"}'\n MethodSettings:\n - HttpMethod: '*'\n ResourcePath: '/*'\n ThrottlingBurstLimit: 10\n ThrottlingRateLimit: 5\n Tags:\n ManagedBy: marketplace-metering-skill\n\n # Access log group for the registration API: explicit, KMS-encrypted,\n # retention-bounded — not an auto-created, unencrypted, never-expiring default.\n ApiAccessLogGroup:\n Type: AWS::Logs::LogGroup\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n Properties:\n LogGroupName: !Sub /aws/apigateway/${StackPrefix}-registration-api/access\n RetentionInDays: !Ref LogRetentionInDays\n KmsKeyId: !If [HasLogsKmsKey, !Ref LogsKmsKeyArn, !Ref 'AWS::NoValue']\n\n # WAF for the unauthenticated registration endpoint (DefaultAuthorizer: NONE — Marketplace\n # POSTs the token unauthenticated). By default the stack CREATES a baseline REGIONAL WebACL\n # (rate-based + AWS common-exploit managed rules) and attaches it to the stage, so the public\n # endpoint is protected out of the box. A seller-supplied WebAclArn overrides the baseline.\n BaselineWebAcl:\n Type: AWS::WAFv2::WebACL\n Condition: UseBaselineWebAcl\n Properties:\n Name: !Sub ${StackPrefix}-registration-waf\n Scope: REGIONAL\n DefaultAction:\n Allow: {}\n VisibilityConfig:\n SampledRequestsEnabled: true\n CloudWatchMetricsEnabled: true\n MetricName: !Sub ${StackPrefix}-registration-waf\n Rules:\n - Name: RateLimit\n Priority: 0\n Action:\n Block: {}\n Statement:\n RateBasedStatement:\n Limit: 2000\n AggregateKeyType: IP\n VisibilityConfig:\n SampledRequestsEnabled: true\n CloudWatchMetricsEnabled: true\n MetricName: !Sub ${StackPrefix}-registration-waf-ratelimit\n - Name: CommonRuleSet\n Priority: 1\n OverrideAction:\n None: {}\n Statement:\n ManagedRuleGroupStatement:\n VendorName: AWS\n Name: AWSManagedRulesCommonRuleSet\n VisibilityConfig:\n SampledRequestsEnabled: true\n CloudWatchMetricsEnabled: true\n MetricName: !Sub ${StackPrefix}-registration-waf-common\n Tags:\n - Key: ManagedBy\n Value: marketplace-metering-skill\n\n # Attach the baseline WebACL (created above) when no seller override is provided.\n RegistrationApiWafAssociationBaseline:\n Type: AWS::WAFv2::WebACLAssociation\n Condition: UseBaselineWebAcl\n # depend on the SAM-generated stage, else the association may run before the\n # stage exists and WAF returns NotFound (the !Sub only implies a dep on the RestApi).\n DependsOn: RegistrationApiStage\n Properties:\n ResourceArn: !Sub arn:aws:apigateway:${AWS::Region}::/restapis/${RegistrationApi}/stages/${StageName}\n WebACLArn: !GetAtt BaselineWebAcl.Arn\n\n # Attach the seller-supplied WebACL instead, when WebAclArn is provided.\n RegistrationApiWafAssociationProvided:\n Type: AWS::WAFv2::WebACLAssociation\n Condition: HasWebAcl\n DependsOn: RegistrationApiStage\n Properties:\n ResourceArn: !Sub arn:aws:apigateway:${AWS::Region}::/restapis/${RegistrationApi}/stages/${StageName}\n WebACLArn: !Ref WebAclArn\n\n RegisterFunction:\n Type: AWS::Serverless::Function\n Properties:\n FunctionName: !Sub ${StackPrefix}-register\n Handler: handlers/register.handler\n CodeUri: src/\n MemorySize: 128\n Environment:\n Variables:\n # Registration input allowlist. The Globals block already sets\n # PRODUCT_CODE / SUBSCRIBERS_TABLE / SUBSCRIBERS_TABLE_REGION.\n ALLOWED_REGISTRATION_FIELDS: !Ref AllowedRegistrationFields\n # In-region customer-profile table where buyer PII (registrationData map +\n # any promoted top-level fields) is stored — NOT the us-east-1 subscribers table.\n CUSTOMER_PROFILE_TABLE: !Ref CustomerProfileTable\n # Comma-separated subset of ALLOWED_REGISTRATION_FIELDS the seller promoted to\n # top-level attributes (for the GSIs defined on the customer-profile table).\n PROMOTED_PROFILE_FIELDS: !Ref PromotedProfileFields\n Policies:\n - Statement:\n # Buyer PII (registrationData + promoted fields) goes to the IN-REGION\n # customer-profile table only.\n - Effect: Allow\n Action:\n - dynamodb:PutItem\n - dynamodb:UpdateItem\n - dynamodb:Query\n Resource:\n - !GetAtt CustomerProfileTable.Arn\n - !Sub ${CustomerProfileTable.Arn}/index/*\n - Statement:\n # us-east-1 subscribers table: PII-FREE. The register Lambda only reads the row\n # (idempotent registeredRegions check) and appends its Region — no PII write.\n - Effect: Allow\n Action:\n - dynamodb:GetItem\n - dynamodb:UpdateItem\n - dynamodb:Query\n Resource:\n - !Sub arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/${SubscribersTableName}\n # Query the customerAWSAccountId GSI to find the subscriber row\n # (Query, not a full-table Scan). No dynamodb:Scan is granted.\n - !Sub arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/${SubscribersTableName}/index/customerAWSAccountId-index\n - Statement:\n - Effect: Allow\n Action: aws-marketplace:ResolveCustomer\n # Resource '*' required — ResolveCustomer does not support resource-level ARNs.\n Resource: '*'\n Tags:\n ManagedBy: marketplace-metering-skill\n Events:\n PostRegistration:\n Type: Api\n Properties:\n RestApiId: !Ref RegistrationApi\n Path: /register\n Method: POST\n\n RegisterLogGroup:\n Type: AWS::Logs::LogGroup\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n Properties:\n LogGroupName: !Sub /aws/lambda/${StackPrefix}-register\n RetentionInDays: !Ref LogRetentionInDays\n KmsKeyId: !If [HasLogsKmsKey, !Ref LogsKmsKeyArn, !Ref 'AWS::NoValue']\n\n # ── Decoupled metering pipeline ────────────────────────────────────────\n # Replaces the single hourly meter Lambda. Discoverer (30 invocations via 6 rules x 5\n # targets) -> work queue -> Aggregator (parallel) -> aggregated_usage + cleanup queue;\n # Cleanup clears raw-row meteringPending; Submitter (reserved=1, scheduled) reads\n # aggregated_usage and calls BatchMeterUsage.\n\n # Discoverer: one function, ReservedConcurrentExecutions=30. Each invocation owns one\n # slice via its constant EventBridge target Input ({mode:meter,hourOffset} or\n # {mode:ageout,shard}). Reads the metering_pending GSI and enqueues one work message per\n # pending group; age-out invocations expire >24h rows directly.\n DiscovererFunction:\n Type: AWS::Serverless::Function\n Condition: IsFullPipeline\n Properties:\n FunctionName: !Sub ${StackPrefix}-discoverer\n Handler: handlers/discoverer.handler\n CodeUri: src/\n MemorySize: 256\n Timeout: 300\n ReservedConcurrentExecutions: 30\n Environment:\n Variables:\n WORK_QUEUE_URL: !Ref MeteringWorkQueue\n DEPROVISION_WORK_QUEUE_URL: !Ref DeprovisionWorkQueue\n METERING_LOCK_HOURS: !Ref MeteringLockHours\n USAGE_TABLE: !Ref UsageTable\n METRIC_NAMESPACE: AwsMarketplace/Metering\n Policies:\n - Statement:\n - Effect: Allow\n Action:\n - dynamodb:Query\n Resource:\n # Discovery queries the metering_pending GSI (and the base table).\n - !GetAtt UsageTable.Arn\n - !Sub ${UsageTable.Arn}/index/metering_pending\n - Effect: Allow\n Action:\n - dynamodb:UpdateItem # age-out terminal update on raw rows\n Resource:\n # Writes authorize against the table ARN only, never an index ARN.\n - !GetAtt UsageTable.Arn\n - Effect: Allow\n Action:\n - dynamodb:Query\n Resource:\n # flush-deprovisioning sweep queries the sparse deprovisioning index on the\n # (us-east-1) subscribers table to find licenses in their ~1h flush window.\n # Query, not Scan.\n - !Sub arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/${SubscribersTableName}/index/deprovisioning-pending-index\n - Statement:\n - Effect: Allow\n Action: sqs:SendMessage\n Resource:\n - !GetAtt MeteringWorkQueue.Arn\n - !GetAtt DeprovisionWorkQueue.Arn\n Tags:\n ManagedBy: marketplace-metering-skill\n\n DiscovererLogGroup:\n Type: AWS::Logs::LogGroup\n Condition: IsFullPipeline\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n Properties:\n LogGroupName: !Sub /aws/lambda/${StackPrefix}-discoverer\n RetentionInDays: !Ref LogRetentionInDays\n KmsKeyId: !If [HasLogsKmsKey, !Ref LogsKmsKeyArn, !Ref 'AWS::NoValue']\n\n # Permission for EventBridge rules to invoke the discoverer.\n DiscovererInvokePermission:\n Type: AWS::Lambda::Permission\n Condition: IsFullPipeline\n Properties:\n FunctionName: !Ref DiscovererFunction\n Action: lambda:InvokeFunction\n Principal: events.amazonaws.com\n SourceArn: !Sub arn:aws:events:${AWS::Region}:${AWS::AccountId}:rule/${StackPrefix}-meter-dispatch-*\n\n # Aggregator: SQS-triggered (work queue). Reads one group, folds + validates, rejects\n # client-side OR conditional-PutItem into aggregated_usage, then enqueues cleanup.\n AggregatorFunction:\n Type: AWS::Serverless::Function\n Condition: IsFullPipeline\n Properties:\n FunctionName: !Sub ${StackPrefix}-aggregator\n Handler: handlers/aggregator.handler\n CodeUri: src/\n MemorySize: 512\n Timeout: 60\n Environment:\n Variables:\n CLEANUP_QUEUE_URL: !Ref MeteringCleanupQueue\n USAGE_TABLE: !Ref UsageTable\n METRIC_NAMESPACE: AwsMarketplace/Metering\n Policies:\n - Statement:\n - Effect: Allow\n Action:\n - dynamodb:Query\n - dynamodb:UpdateItem # client-side reject writes terminal status on raw rows\n Resource:\n # Base table only: the aggregator reads a group via a base-table\n # begins_with Query (never the metering_pending GSI — that's the\n # discoverer's), and UpdateItem authorizes against the table ARN, not an\n # index ARN.\n - !GetAtt UsageTable.Arn\n - Statement:\n - Effect: Allow\n Action:\n - dynamodb:PutItem # conditional put (commit point)\n Resource:\n - !GetAtt AggregatedUsageTable.Arn\n - Statement:\n - Effect: Allow\n Action: sqs:SendMessage\n Resource: !GetAtt MeteringCleanupQueue.Arn\n Tags:\n ManagedBy: marketplace-metering-skill\n Events:\n WorkQueue:\n Type: SQS\n Properties:\n Queue: !GetAtt MeteringWorkQueue.Arn\n BatchSize: 10\n FunctionResponseTypes:\n - ReportBatchItemFailures\n ScalingConfig:\n # Option A ( decision 1): cap fan-out so downstream stays within limits.\n MaximumConcurrency: 50\n DeprovisionWorkQueue:\n Type: SQS\n Properties:\n # Dedicated deprovisioning flush queue — SAME aggregator function, isolated ESM so\n # a deprovisioning backlog drains independently of the regular work backlog.\n Queue: !GetAtt DeprovisionWorkQueue.Arn\n BatchSize: 10\n FunctionResponseTypes:\n - ReportBatchItemFailures\n ScalingConfig:\n MaximumConcurrency: 50\n\n AggregatorLogGroup:\n Type: AWS::Logs::LogGroup\n Condition: IsFullPipeline\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n Properties:\n LogGroupName: !Sub /aws/lambda/${StackPrefix}-aggregator\n RetentionInDays: !Ref LogRetentionInDays\n KmsKeyId: !If [HasLogsKmsKey, !Ref LogsKmsKeyArn, !Ref 'AWS::NoValue']\n\n # Cleanup: SQS-triggered (cleanup queue). Finalizes raw usage rows: clears\n # meteringPending and stamps meteringStatus=Aggregated + \"Aggregated total quantity \u003csum>\"\n # (per-key UpdateItem; DynamoDB BatchWriteItem cannot partially update an attribute).\n CleanupFunction:\n Type: AWS::Serverless::Function\n Condition: IsFullPipeline\n Properties:\n FunctionName: !Sub ${StackPrefix}-cleanup\n Handler: handlers/cleanup.handler\n CodeUri: src/\n MemorySize: 256\n Timeout: 120\n Environment:\n Variables:\n # Bounded thread-pool size for parallel per-key UpdateItem REMOVE; guards\n # the ~1000 WCU/sec per-partition limit (all rows share the licenseArn partition).\n CLEANUP_MAX_WORKERS: '10'\n USAGE_TABLE: !Ref UsageTable\n METRIC_NAMESPACE: AwsMarketplace/Metering\n Policies:\n - Statement:\n - Effect: Allow\n Action:\n - dynamodb:UpdateItem\n Resource:\n - !GetAtt UsageTable.Arn\n Tags:\n ManagedBy: marketplace-metering-skill\n Events:\n CleanupQueue:\n Type: SQS\n Properties:\n Queue: !GetAtt MeteringCleanupQueue.Arn\n BatchSize: 10\n # Drain as fast as the DynamoDB hot-partition limit allows (the real bound, NOT\n # SQS): pick messages up immediately (no batching wait) and raise the concurrency\n # cap. The global instantaneous writer count is still MaximumConcurrency ×\n # CLEANUP_MAX_WORKERS; keep it under the ~1000 WCU/s per-licenseArn-partition\n # limit. 10 × 10 = 100 concurrent surgical UpdateItems, which drains the queue\n # quickly while a per-invocation backoff absorbs any transient throttling.\n MaximumBatchingWindowInSeconds: 0\n FunctionResponseTypes:\n - ReportBatchItemFailures\n ScalingConfig:\n # Bound GLOBAL cleanup concurrency, not just the per-invocation thread pool.\n # All rows of a group share the licenseArn partition (~1000 WCU/s); a hot\n # license fans out into up to ~36 cleanup messages, so without this cap many\n # invocations (each with CLEANUP_MAX_WORKERS threads) could hammer one\n # partition. Global writers ≈ MaximumConcurrency × CLEANUP_MAX_WORKERS.\n MaximumConcurrency: 10\n\n CleanupLogGroup:\n Type: AWS::Logs::LogGroup\n Condition: IsFullPipeline\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n Properties:\n LogGroupName: !Sub /aws/lambda/${StackPrefix}-cleanup\n RetentionInDays: !Ref LogRetentionInDays\n KmsKeyId: !If [HasLogsKmsKey, !Ref LogsKmsKeyArn, !Ref 'AWS::NoValue']\n\n # Submitter: EventBridge-scheduled, ReservedConcurrentExecutions=1 (single serial\n # submitter — safe vs. the BatchMeterUsage rate limit). Reads aggregated_usage pending,\n # coalesces \u003c=25 -> BatchMeterUsage, writes back to aggregated_usage.\n SubmitterFunction:\n Type: AWS::Serverless::Function\n Properties:\n FunctionName: !Sub ${StackPrefix}-submitter\n Handler: handlers/submitter.handler\n CodeUri: src/\n MemorySize: 256\n Timeout: 300\n ReservedConcurrentExecutions: 1\n Environment:\n Variables:\n # >0 => set a `ttl` epoch on finalized Success rows (AggregatedUsageTableTtlDays);\n # 0 => never set ttl (table has no TTL spec, so this is a no-op anyway).\n AGGREGATED_USAGE_TTL_DAYS: !Ref AggregatedUsageTableTtlDays\n METRIC_NAMESPACE: AwsMarketplace/Metering\n # live => real BatchMeterUsage; dry-run => full pipeline but NO real submit\n # (writes DryRunSubmitted). Non-prod sandbox stages set dry-run.\n METERING_MODE: !Ref MeteringMode\n Policies:\n - Statement:\n - Effect: Allow\n Action:\n # Query the sparse deprovisioning index to prioritize deprovisioning-license\n # records first (submitter no longer finalizes subscribers — that moved to the\n # events-stack deprovision-cleanup Lambda). Query, not Scan; index only.\n - dynamodb:Query\n Resource:\n - !Sub arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/${SubscribersTableName}/index/deprovisioning-pending-index\n - Statement:\n - Effect: Allow\n Action:\n - dynamodb:Query\n - dynamodb:UpdateItem\n Resource:\n - !GetAtt AggregatedUsageTable.Arn\n - !Sub ${AggregatedUsageTable.Arn}/index/metering_pending\n - Statement:\n - Effect: Allow\n Action: aws-marketplace:BatchMeterUsage\n # Resource '*' required — BatchMeterUsage does not support resource-level ARNs.\n Resource: '*'\n Tags:\n ManagedBy: marketplace-metering-skill\n # NOTE: no SAM Schedule event here — the submitter is a target of the shared\n # SubmitDispatchRule below (same rate(5 minutes) rule that also fires the expiry\n # Lambda), so the two run independently off ONE rule.\n\n SubmitterLogGroup:\n Type: AWS::Logs::LogGroup\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n Properties:\n LogGroupName: !Sub /aws/lambda/${StackPrefix}-submitter\n RetentionInDays: !Ref LogRetentionInDays\n KmsKeyId: !If [HasLogsKmsKey, !Ref LogsKmsKeyArn, !Ref 'AWS::NoValue']\n\n # Submission-expiry: scheduled (shared rule below), reserved=1. Sweeps aggregated_usage and\n # terminally expires pending records past the 24h submittable window (and, once the\n # month-end grace closes at 06:00 UTC on the 1st, previous-month records) — so a record\n # the submitter never drained is never left pending forever, and the loss is alarmed via\n # the UsageSubmissionExpired metric (distinct from the raw table's UsageAggregationExpired\n # age-out). aggregated_usage only; the raw table's age-out is the discoverer's ageout shards.\n SubmissionExpiryFunction:\n Type: AWS::Serverless::Function\n Properties:\n FunctionName: !Sub ${StackPrefix}-submission-expiry\n Handler: handlers/expiry.handler\n CodeUri: src/\n MemorySize: 256\n Timeout: 300\n ReservedConcurrentExecutions: 1\n Environment:\n Variables:\n METRIC_NAMESPACE: AwsMarketplace/Metering\n Policies:\n - Statement:\n - Effect: Allow\n Action:\n - dynamodb:Query\n Resource:\n - !GetAtt AggregatedUsageTable.Arn\n - !Sub ${AggregatedUsageTable.Arn}/index/metering_pending\n - Effect: Allow\n Action:\n - dynamodb:UpdateItem # terminal SubmissionExpired write; table ARN only\n Resource:\n - !GetAtt AggregatedUsageTable.Arn\n Tags:\n ManagedBy: marketplace-metering-skill\n\n SubmissionExpiryLogGroup:\n Type: AWS::Logs::LogGroup\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n Properties:\n LogGroupName: !Sub /aws/lambda/${StackPrefix}-submission-expiry\n RetentionInDays: !Ref LogRetentionInDays\n KmsKeyId: !If [HasLogsKmsKey, !Ref LogsKmsKeyArn, !Ref 'AWS::NoValue']\n\n # One rate(5 minutes) rule fires BOTH the submitter and the submission-expiry Lambda\n # (independent targets, each reserved=1). Submitter drains the now-23h..now-1h window\n # oldest-first; expiry sweeps aggregated_usage for records past the window.\n SubmitDispatchRule:\n Type: AWS::Events::Rule\n Properties:\n Name: !Sub ${StackPrefix}-submit-dispatch\n ScheduleExpression: rate(5 minutes)\n State: ENABLED\n Targets:\n - Id: submitter\n Arn: !GetAtt SubmitterFunction.Arn\n - Id: expiry\n Arn: !GetAtt SubmissionExpiryFunction.Arn\n\n SubmitterInvokePermission:\n Type: AWS::Lambda::Permission\n Properties:\n FunctionName: !Ref SubmitterFunction\n Action: lambda:InvokeFunction\n Principal: events.amazonaws.com\n SourceArn: !GetAtt SubmitDispatchRule.Arn\n\n SubmissionExpiryInvokePermission:\n Type: AWS::Lambda::Permission\n Properties:\n FunctionName: !Ref SubmissionExpiryFunction\n Action: lambda:InvokeFunction\n Principal: events.amazonaws.com\n SourceArn: !GetAtt SubmitDispatchRule.Arn\n\n # Expedited deprovisioning flush: a rate(5m) rule invokes the EXISTING discoverer\n # in flush-deprovisioning mode. It queries the sparse deprovisioning-pending-index and\n # enqueues each deprovisioning license's now-23h..now-1h pending groups to the work queue,\n # BYPASSING MeteringLockHours so the ~1h flush window is met. This is ADDITIVE and\n # independent of the ordinary hourly meter/ageout rules (which are unchanged).\n FlushDeprovisioningRule:\n Type: AWS::Events::Rule\n Condition: IsFullPipeline\n Properties:\n Name: !Sub ${StackPrefix}-flush-deprovisioning\n ScheduleExpression: rate(5 minutes)\n State: ENABLED\n Targets:\n - Id: discoverer-flush-deprovisioning\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"flush-deprovisioning\"}'\n\n FlushDeprovisioningInvokePermission:\n Type: AWS::Lambda::Permission\n Condition: IsFullPipeline\n Properties:\n FunctionName: !Ref DiscovererFunction\n Action: lambda:InvokeFunction\n Principal: events.amazonaws.com\n SourceArn: !GetAtt FlushDeprovisioningRule.Arn\n\n # ── Discoverer schedule: 6 rules x 5 constant-Input targets = 30 invocations ──────\n # 23 meter-hour offsets + 7 age-out shards. Slice identity is baked into each target's\n # Input (single source of truth); the discoverer derives the hour as floor(now,h)-offset,\n # so buckets are disjoint and gap-free. EventBridge caps a rule at 5 targets, so\n # 30 invocations require 6 rules.\n MeterDispatchRule1:\n Type: AWS::Events::Rule\n Condition: IsFullPipeline\n Properties:\n Name: !Sub ${StackPrefix}-meter-dispatch-1\n ScheduleExpression: rate(15 minutes)\n State: ENABLED\n Targets:\n - Id: h1\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":1}'\n - Id: h2\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":2}'\n - Id: h3\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":3}'\n - Id: h4\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":4}'\n - Id: h5\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":5}'\n\n MeterDispatchRule2:\n Type: AWS::Events::Rule\n Condition: IsFullPipeline\n Properties:\n Name: !Sub ${StackPrefix}-meter-dispatch-2\n ScheduleExpression: rate(15 minutes)\n State: ENABLED\n Targets:\n - Id: h6\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":6}'\n - Id: h7\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":7}'\n - Id: h8\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":8}'\n - Id: h9\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":9}'\n - Id: h10\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":10}'\n\n MeterDispatchRule3:\n Type: AWS::Events::Rule\n Condition: IsFullPipeline\n Properties:\n Name: !Sub ${StackPrefix}-meter-dispatch-3\n ScheduleExpression: rate(15 minutes)\n State: ENABLED\n Targets:\n - Id: h11\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":11}'\n - Id: h12\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":12}'\n - Id: h13\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":13}'\n - Id: h14\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":14}'\n - Id: h15\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":15}'\n\n MeterDispatchRule4:\n Type: AWS::Events::Rule\n Condition: IsFullPipeline\n Properties:\n Name: !Sub ${StackPrefix}-meter-dispatch-4\n ScheduleExpression: rate(15 minutes)\n State: ENABLED\n Targets:\n - Id: h16\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":16}'\n - Id: h17\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":17}'\n - Id: h18\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":18}'\n - Id: h19\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":19}'\n - Id: h20\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":20}'\n\n MeterDispatchRule5:\n Type: AWS::Events::Rule\n Condition: IsFullPipeline\n Properties:\n Name: !Sub ${StackPrefix}-meter-dispatch-5\n ScheduleExpression: rate(15 minutes)\n State: ENABLED\n Targets:\n - Id: h21\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":21}'\n - Id: h22\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":22}'\n - Id: h23\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"meter\",\"hourOffset\":23}'\n - Id: a0\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"ageout\",\"shard\":0}'\n - Id: a1\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"ageout\",\"shard\":1}'\n\n MeterDispatchRule6:\n Type: AWS::Events::Rule\n Condition: IsFullPipeline\n Properties:\n Name: !Sub ${StackPrefix}-meter-dispatch-6\n ScheduleExpression: rate(15 minutes)\n State: ENABLED\n Targets:\n - Id: a2\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"ageout\",\"shard\":2}'\n - Id: a3\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"ageout\",\"shard\":3}'\n - Id: a4\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"ageout\",\"shard\":4}'\n - Id: a5\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"ageout\",\"shard\":5}'\n - Id: a6\n Arn: !GetAtt DiscovererFunction.Arn\n Input: '{\"mode\":\"ageout\",\"shard\":6}'\n\n # ── Monitoring & Alarms (created in the SELLER's account) ─────────────────────────\n # These alarms cover the minimum revenue-loss signals. Alarms are ALWAYS created; each\n # alarm's AlarmActions wire to an OPTIONAL seller-supplied in-region SNS topic\n # (AlertsTopicArn) so observability does not depend on pre-supplying a topic.\n #\n # Handling alarms and acting on failures is the SELLER's responsibility — AWS\n # Marketplace and this skill do NOT track or act on client-side (seller-account)\n # errors. TODO(seller): supply AlertsTopicArn (an in-region SNS topic) and set your own\n # thresholds/actions; neither the deployer role nor any Lambda publishes to the topic\n # (CloudWatch fires the action). Business-status metrics are emitted via EMF from the\n # meter Lambda logs (below) to avoid extra runtime IAM.\n\n # 1) Pipeline Lambda Errors — the discoverer, submitter, and expiry RAISE on failure, so\n # their Lambda Errors metric increments and this alarm fires. NOTE: the aggregator and\n # cleanup use ReportBatchItemFailures, so a per-message failure does NOT increment their\n # Errors metric (only a top-level config error would) — their real failure signal is the\n # work/cleanup DLQ-depth + work-queue-backlog alarms below. This metric-math alarm sums\n # the raising functions' Errors.\n MeteringErrorAlarm:\n Type: AWS::CloudWatch::Alarm\n Condition: IsFullPipeline\n Properties:\n AlarmName: !Sub ${StackPrefix}-meter-errors\n AlarmDescription: Metering pipeline Lambda errors (discoverer/submitter/expiry raise; aggregator/cleanup via DLQ alarms) - potential revenue loss\n Metrics:\n - Id: e1\n MetricStat:\n Metric:\n Namespace: AWS/Lambda\n MetricName: Errors\n Dimensions:\n - Name: FunctionName\n Value: !Ref DiscovererFunction\n Period: 3600\n Stat: Sum\n ReturnData: false\n - Id: e2\n MetricStat:\n Metric:\n Namespace: AWS/Lambda\n MetricName: Errors\n Dimensions:\n - Name: FunctionName\n Value: !Ref AggregatorFunction\n Period: 3600\n Stat: Sum\n ReturnData: false\n - Id: e3\n MetricStat:\n Metric:\n Namespace: AWS/Lambda\n MetricName: Errors\n Dimensions:\n - Name: FunctionName\n Value: !Ref SubmitterFunction\n Period: 3600\n Stat: Sum\n ReturnData: false\n - Id: e4\n MetricStat:\n Metric:\n Namespace: AWS/Lambda\n MetricName: Errors\n Dimensions:\n - Name: FunctionName\n Value: !Ref SubmissionExpiryFunction\n Period: 3600\n Stat: Sum\n ReturnData: false\n - Id: total\n Expression: e1 + e2 + e3 + e4\n Label: PipelineErrors\n ReturnData: true\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # Direct-submit mode: only the submitter + submission-expiry exist, so the Errors alarm\n # covers just those two.\n MeteringErrorAlarmDirect:\n Type: AWS::CloudWatch::Alarm\n Condition: IsDirectSubmit\n Properties:\n AlarmName: !Sub ${StackPrefix}-meter-errors\n AlarmDescription: Metering Lambda errors (submitter/submission-expiry) - potential revenue loss\n Metrics:\n - Id: e3\n MetricStat:\n Metric:\n Namespace: AWS/Lambda\n MetricName: Errors\n Dimensions:\n - Name: FunctionName\n Value: !Ref SubmitterFunction\n Period: 3600\n Stat: Sum\n ReturnData: false\n - Id: e4\n MetricStat:\n Metric:\n Namespace: AWS/Lambda\n MetricName: Errors\n Dimensions:\n - Name: FunctionName\n Value: !Ref SubmissionExpiryFunction\n Period: 3600\n Stat: Sum\n ReturnData: false\n - Id: total\n Expression: e3 + e4\n Label: PipelineErrors\n ReturnData: true\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 2) Submitter Throttles — the submitter is reserved=1 (safe vs. BatchMeterUsage rate\n # limit); throttling here means aggregated usage is not being submitted promptly.\n MeteringThrottleAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n AlarmName: !Sub ${StackPrefix}-meter-throttles\n AlarmDescription: Submitter throttled - aggregated usage may not be submitted in time\n Namespace: AWS/Lambda\n MetricName: Throttles\n Dimensions:\n - Name: FunctionName\n Value: !Ref SubmitterFunction\n Statistic: Sum\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 3) \"Submitter did not run\" — the submitter runs on a schedule; if it stops being\n # invoked (a silent scheduler failure emits no error) aggregated usage silently stops\n # being submitted. Missing data is treated as BREACHING so a total stall is caught.\n MeteringDidNotRunAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n AlarmName: !Sub ${StackPrefix}-meter-not-run\n AlarmDescription: Submitter has not run recently - aggregated usage is not being submitted\n Namespace: AWS/Lambda\n MetricName: Invocations\n Dimensions:\n - Name: FunctionName\n Value: !Ref SubmitterFunction\n Statistic: Sum\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: LessThanThreshold\n TreatMissingData: breaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 3-disc) \"Discoverer did not run\" — CRITICAL: the 6 EventBridge dispatch rules drive the\n # discoverer, which is the ONLY thing that enqueues work AND the ONLY thing that ages\n # out >24h usage. If the rules stop firing (disabled, PutTargets regression, permission\n # removed) NOTHING is enqueued and NOTHING ages out — yet the submitter keeps running\n # on an empty aggregated_usage, so every other alarm stays green. Without this alarm a\n # total metering stall is SILENT. 30 invocations/hour are expected; alarm if fewer than\n # 1 in a 2h window. Missing data is BREACHING so a full stop is caught.\n DiscovererDidNotRunAlarm:\n Type: AWS::CloudWatch::Alarm\n Condition: IsFullPipeline\n Properties:\n AlarmName: !Sub ${StackPrefix}-discoverer-not-run\n AlarmDescription: Discoverer has not run recently - usage is neither being metered nor aged out (silent stall)\n Namespace: AWS/Lambda\n MetricName: Invocations\n Dimensions:\n - Name: FunctionName\n Value: !Ref DiscovererFunction\n Statistic: Sum\n Period: 7200\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: LessThanThreshold\n TreatMissingData: breaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 3a) Work DLQ depth — a message that fails the aggregator maxReceiveCount times lands\n # here; it represents a group that could not be aggregated (investigate + redrive).\n WorkDLQDepthAlarm:\n Type: AWS::CloudWatch::Alarm\n Condition: IsFullPipeline\n Properties:\n AlarmName: !Sub ${StackPrefix}-work-dlq-depth\n AlarmDescription: Metering work DLQ has messages - groups failed aggregation\n Namespace: AWS/SQS\n MetricName: ApproximateNumberOfMessagesVisible\n Dimensions:\n - Name: QueueName\n Value: !GetAtt MeteringWorkDLQ.QueueName\n Statistic: Maximum\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 3a-ii) Deprovision-work DLQ depth — a deprovisioning-flush group that failed aggregation\n # maxReceiveCount times. Higher urgency than the regular work DLQ: these are licenses in\n # their ~1h flush window, so a stuck message risks missing the window (lost revenue).\n DeprovisionWorkDLQDepthAlarm:\n Type: AWS::CloudWatch::Alarm\n Condition: IsFullPipeline\n Properties:\n AlarmName: !Sub ${StackPrefix}-deprovision-work-dlq-depth\n AlarmDescription: Deprovision-work DLQ has messages - a deprovisioning flush failed aggregation (risks missing the ~1h window)\n Namespace: AWS/SQS\n MetricName: ApproximateNumberOfMessagesVisible\n Dimensions:\n - Name: QueueName\n Value: !GetAtt DeprovisionWorkDLQ.QueueName\n Statistic: Maximum\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 3b) Cleanup DLQ depth — a cleanup message that fails maxReceiveCount times lands here;\n # raw-row meteringPending was not cleared (rows re-discovered next hour, but the\n # aggregator's conditional put no-ops, so this is a health signal, not revenue loss).\n CleanupDLQDepthAlarm:\n Type: AWS::CloudWatch::Alarm\n Condition: IsFullPipeline\n Properties:\n AlarmName: !Sub ${StackPrefix}-cleanup-dlq-depth\n AlarmDescription: Metering cleanup DLQ has messages - raw-row meteringPending not cleared\n Namespace: AWS/SQS\n MetricName: ApproximateNumberOfMessagesVisible\n Dimensions:\n - Name: QueueName\n Value: !GetAtt MeteringCleanupDLQ.QueueName\n Statistic: Maximum\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 3c) Work queue oldest-message age — a STUCK aggregator (event source mapping disabled,\n # account-level concurrency starvation, or persistent aggregator throttling) leaves\n # messages sitting in the WORK queue (not the DLQ) and aging. Waiting for them to hit\n # maxReceiveCount and DLQ would delay detection; this catches the backlog directly.\n # Threshold 3h (10800s) tolerates the MeteringLockHours delay + normal drain.\n WorkQueueBacklogAlarm:\n Type: AWS::CloudWatch::Alarm\n Condition: IsFullPipeline\n Properties:\n AlarmName: !Sub ${StackPrefix}-work-queue-backlog\n AlarmDescription: Work queue messages aging - aggregator may be stuck (usage not being aggregated)\n Namespace: AWS/SQS\n MetricName: ApproximateAgeOfOldestMessage\n Dimensions:\n - Name: QueueName\n Value: !GetAtt MeteringWorkQueue.QueueName\n Statistic: Maximum\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 10800\n ComparisonOperator: GreaterThanThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # Deprovision-work queue oldest-message-age. Higher urgency than the regular work queue:\n # a stuck message risks missing the ~1h flush window (CustomerNotSubscribed = lost revenue).\n # A DLQ-depth signal alone fires too late (maxReceiveCount 5 x VisibilityTimeout 360s ~= 30\n # min already consumes half the window), so alarm on age at 15 min — well under the ~1h\n # window — to catch a stalled aggregator ESM before the window closes.\n DeprovisionWorkQueueAgeAlarm:\n Type: AWS::CloudWatch::Alarm\n Condition: IsFullPipeline\n Properties:\n AlarmName: !Sub ${StackPrefix}-deprovision-work-queue-age\n AlarmDescription: Deprovision-work queue messages aging - a deprovisioning flush is stalled and risks missing the ~1h window\n Namespace: AWS/SQS\n MetricName: ApproximateAgeOfOldestMessage\n Dimensions:\n - Name: QueueName\n Value: !GetAtt DeprovisionWorkQueue.QueueName\n Statistic: Maximum\n Period: 300\n EvaluationPeriods: 1\n Threshold: 900\n ComparisonOperator: GreaterThanThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 3d) Aggregator throttles — the aggregator has no reserved concurrency (only an ESM\n # MaximumConcurrency cap), so account-level concurrency pressure can throttle it,\n # stalling aggregation. Caught here directly rather than only via the eventual DLQ.\n AggregatorThrottleAlarm:\n Type: AWS::CloudWatch::Alarm\n Condition: IsFullPipeline\n Properties:\n AlarmName: !Sub ${StackPrefix}-aggregator-throttles\n AlarmDescription: Aggregator throttled - usage may not be aggregated in time\n Namespace: AWS/Lambda\n MetricName: Throttles\n Dimensions:\n - Name: FunctionName\n Value: !Ref AggregatorFunction\n Statistic: Sum\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 3e) CustomerNotSubscribed — a TERMINAL per-record BatchMeterUsage status (NOT retried,\n # so it never raises a Lambda error). A spike means real usage is being dropped\n # (mass cancellation window, or a bug submitting for inactive licenses) — revenue\n # loss that would otherwise be SILENT. EMF metric emitted by the submitter.\n CustomerNotSubscribedAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n AlarmName: !Sub ${StackPrefix}-customer-not-subscribed\n AlarmDescription: BatchMeterUsage returned CustomerNotSubscribed - usage dropped for inactive/expired licenses\n Namespace: AwsMarketplace/Metering\n MetricName: CustomerNotSubscribed\n Dimensions:\n - Name: ProductCode\n Value: !Ref ProductCode\n Statistic: Sum\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 3f) BatchMeterUsageException — a REQUEST-level BatchMeterUsage exception\n # (InvalidUsageDimensionException, TimestampOutOfBoundsException, InvalidTagException,\n # InvalidProductCodeException, InvalidLicenseException, InvalidUsageAllocationsException, ...) that does NOT raise a Lambda error. The submitter isolates the\n # offending record (bisect) and, when it stamps that record's real terminal status, emits this\n # metric dimensioned by [Exception, ProductCode]. Any of these means usage was NOT billed\n # (mis-config / bad input) — silent revenue loss without this alarm. The alarm sums across all\n # exception types via a Metrics Insights expression (the per-exception breakdown is on the\n # dashboard \"BatchMeterUsage exceptions by type\" widget).\n BatchMeterUsageExceptionAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n AlarmName: !Sub ${StackPrefix}-batchmeterusage-exception\n AlarmDescription: BatchMeterUsage returned a request-level exception (dimension/timestamp/tag/product-code/license) - usage not billed\n Metrics:\n - Id: e1\n Expression: !Sub \"SELECT SUM(BatchMeterUsageException) FROM \\\"AwsMarketplace/Metering\\\" WHERE ProductCode = '${ProductCode}'\"\n Label: BatchMeterUsageException (all types)\n ReturnData: true\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 4a) Raw usage aged out BEFORE aggregation (>24h) — a raw row the discoverer aged out\n # because it was never rolled into an aggregated record. The discoverer emits the EMF\n # `UsageAggregationExpired` metric (namespace AwsMarketplace/Metering, dimension\n # ProductCode), CloudWatch auto-publishes it (no MetricFilter — that would double-ingest),\n # and this alarm watches it. Distinct from 4b so an operator sees WHICH stage lost revenue.\n UsageAggregationExpiredAlarm:\n Type: AWS::CloudWatch::Alarm\n Condition: IsFullPipeline\n Properties:\n AlarmName: !Sub ${StackPrefix}-usage-aggregation-expired\n AlarmDescription: Raw usage aged out past 24h before it was aggregated (lost before aggregation)\n Namespace: AwsMarketplace/Metering\n MetricName: UsageAggregationExpired\n Dimensions:\n - Name: ProductCode\n Value: !Ref ProductCode\n Statistic: Sum\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 4b) Aggregated record expired BEFORE submission — a record that WAS aggregated but the\n # submitter never drained before the 24h window (or the month-end grace) closed. The\n # submission-expiry Lambda emits the EMF `UsageSubmissionExpired` metric. Distinct from 4a.\n UsageSubmissionExpiredAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n AlarmName: !Sub ${StackPrefix}-usage-submission-expired\n AlarmDescription: Aggregated record aged out before submission (aggregated but never billed)\n Namespace: AwsMarketplace/Metering\n MetricName: UsageSubmissionExpired\n Dimensions:\n - Name: ProductCode\n Value: !Ref ProductCode\n Statistic: Sum\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # 5) Client-side rejections — usage rejected before submit for a specific,\n # correctable reason (bad quantity/dimension/timestamp/identifier/allocation). The\n # meter Lambda emits the EMF `UsageRecordRejected` metric on the [ProductCode]\n # dimension (this alarm) AND on [Reason, ProductCode] so the seller can slice by\n # reason, then fix at their source and resubmit (the row was never sent, so it is\n # still meterable within 24h). Seller-owned action, like the other alarms.\n UsageRecordRejectedAlarm:\n Type: AWS::CloudWatch::Alarm\n Condition: IsFullPipeline\n Properties:\n AlarmName: !Sub ${StackPrefix}-usage-rejected\n AlarmDescription: Usage rejected client-side before metering - seller must fix the source rows and resubmit\n # UsageRecordRejected is emitted on a single [Reason, MeteringMode, ProductCode] dimension set\n # (no bare [ProductCode] series — that would resurface an \"Other\" bucket on the by-Reason\n # widget and, if also emitted, double-count it). Sum across all reasons via Metrics Insights\n # so the alarm still fires regardless of which reason codes are present (the per-reason\n # breakdown is on the \"Client-side rejections by reason\" dashboard widget).\n Metrics:\n - Id: e1\n Expression: !Sub \"SELECT SUM(UsageRecordRejected) FROM \\\"AwsMarketplace/Metering\\\" WHERE ProductCode = '${ProductCode}'\"\n Label: UsageRecordRejected (all reasons)\n ReturnData: true\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n UsageRecordUnprocessedAlarm:\n Type: AWS::CloudWatch::Alarm\n Properties:\n AlarmName: !Sub ${StackPrefix}-usage-unprocessed\n AlarmDescription: >-\n BatchMeterUsage returned UnprocessedRecords that were STILL unprocessed after the\n submitter's one automatic retry. These records were NOT metered on this run; they are\n left pending and retried on the next submitter cycle. A persistent (repeating) breach\n means records are being rejected/failing to meter and need investigation (throttling,\n timestamp bounds, or a downstream issue) - the seller may lose revenue if it does not clear.\n # UsageRecordUnprocessed is emitted on the bare [ProductCode] dimension (count of\n # still-unprocessed records after the single retry). Sum across the product via Metrics\n # Insights so the alarm is robust regardless of dimension presence, consistent with the\n # UsageRecordRejected alarm above. The submitter does NOT raise on this (it returns and\n # leaves the records pending), so this metric is the ONLY alarm-able signal for it -\n # it does not trip the Lambda Errors alarm.\n Metrics:\n - Id: e1\n Expression: !Sub \"SELECT SUM(UsageRecordUnprocessed) FROM \\\"AwsMarketplace/Metering\\\" WHERE ProductCode = '${ProductCode}'\"\n Label: UsageRecordUnprocessed (still unprocessed after retry)\n ReturnData: true\n Period: 3600\n EvaluationPeriods: 1\n Threshold: 1\n ComparisonOperator: GreaterThanOrEqualToThreshold\n TreatMissingData: notBreaching\n AlarmActions:\n - !If [HasAlertsTopic, !Ref AlertsTopicArn, !Ref 'AWS::NoValue']\n\n # ── Health dashboard (per-product; ) ─────────────────────────────────────\n # Complete pipeline visibility in ONE place so an operator sees health without drilling\n # into individual alarms. HEALTH-only (no business metrics). Created by default; set\n # CreateDashboard=false to skip, or delete/customize after deploy.\n MeteringHealthDashboard:\n Type: AWS::CloudWatch::Dashboard\n Condition: CreateFullDashboard\n Properties:\n # Dashboard names are account-global (NOT scoped per region), so the same\n # per-product StackPrefix deployed to two Regions would collide. Keep the\n # Region in the name to make it unique per Region.\n DashboardName: !Sub ${StackPrefix}-metering-health-${AWS::Region}\n DashboardBody: !Sub |\n {\n \"widgets\": [\n {\n \"type\": \"text\", \"x\": 0, \"y\": 0, \"width\": 24, \"height\": 1,\n \"properties\": {\"markdown\": \"# Metering pipeline health — ${StackPrefix} (product ${ProductCode})\"}\n },\n {\n \"type\": \"metric\", \"x\": 0, \"y\": 1, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"Lambda Errors (all stages)\",\n \"region\": \"${AWS::Region}\", \"stat\": \"Sum\", \"period\": 300,\n \"metrics\": [\n [\"AWS/Lambda\", \"Errors\", \"FunctionName\", \"${DiscovererFunction}\", {\"label\": \"discoverer\"}],\n [\"...\", \"${AggregatorFunction}\", {\"label\": \"aggregator\"}],\n [\"...\", \"${CleanupFunction}\", {\"label\": \"cleanup\"}],\n [\"...\", \"${SubmitterFunction}\", {\"label\": \"submitter\"}]\n ]\n }\n },\n {\n \"type\": \"metric\", \"x\": 12, \"y\": 1, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"Lambda Throttles (all stages)\",\n \"region\": \"${AWS::Region}\", \"stat\": \"Sum\", \"period\": 300,\n \"metrics\": [\n [\"AWS/Lambda\", \"Throttles\", \"FunctionName\", \"${DiscovererFunction}\", {\"label\": \"discoverer\"}],\n [\"...\", \"${AggregatorFunction}\", {\"label\": \"aggregator\"}],\n [\"...\", \"${CleanupFunction}\", {\"label\": \"cleanup\"}],\n [\"...\", \"${SubmitterFunction}\", {\"label\": \"submitter\"}]\n ]\n }\n },\n {\n \"type\": \"metric\", \"x\": 0, \"y\": 7, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"Lambda Invocations (all stages)\",\n \"region\": \"${AWS::Region}\", \"stat\": \"Sum\", \"period\": 300,\n \"metrics\": [\n [\"AWS/Lambda\", \"Invocations\", \"FunctionName\", \"${DiscovererFunction}\", {\"label\": \"discoverer\"}],\n [\"...\", \"${AggregatorFunction}\", {\"label\": \"aggregator\"}],\n [\"...\", \"${CleanupFunction}\", {\"label\": \"cleanup\"}],\n [\"...\", \"${SubmitterFunction}\", {\"label\": \"submitter\"}]\n ]\n }\n },\n {\n \"type\": \"metric\", \"x\": 12, \"y\": 7, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"Lambda Duration p95 (ms)\",\n \"region\": \"${AWS::Region}\", \"stat\": \"p95\", \"period\": 300,\n \"metrics\": [\n [\"AWS/Lambda\", \"Duration\", \"FunctionName\", \"${DiscovererFunction}\", {\"label\": \"discoverer\"}],\n [\"...\", \"${AggregatorFunction}\", {\"label\": \"aggregator\"}],\n [\"...\", \"${CleanupFunction}\", {\"label\": \"cleanup\"}],\n [\"...\", \"${SubmitterFunction}\", {\"label\": \"submitter\"}]\n ]\n }\n },\n {\n \"type\": \"metric\", \"x\": 0, \"y\": 13, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"Queue depth (visible messages)\",\n \"region\": \"${AWS::Region}\", \"stat\": \"Maximum\", \"period\": 300,\n \"metrics\": [\n [\"AWS/SQS\", \"ApproximateNumberOfMessagesVisible\", \"QueueName\", \"${MeteringWorkQueue.QueueName}\", {\"label\": \"work\"}],\n [\"...\", \"${DeprovisionWorkQueue.QueueName}\", {\"label\": \"deprovision-work\"}],\n [\"...\", \"${MeteringCleanupQueue.QueueName}\", {\"label\": \"cleanup\"}],\n [\"...\", \"${MeteringWorkDLQ.QueueName}\", {\"label\": \"work-DLQ\"}],\n [\"...\", \"${DeprovisionWorkDLQ.QueueName}\", {\"label\": \"deprovision-work-DLQ\"}],\n [\"...\", \"${MeteringCleanupDLQ.QueueName}\", {\"label\": \"cleanup-DLQ\"}]\n ]\n }\n },\n {\n \"type\": \"metric\", \"x\": 12, \"y\": 13, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"Work queue oldest-message age (s)\",\n \"region\": \"${AWS::Region}\", \"stat\": \"Maximum\", \"period\": 300,\n \"metrics\": [\n [\"AWS/SQS\", \"ApproximateAgeOfOldestMessage\", \"QueueName\", \"${MeteringWorkQueue.QueueName}\", {\"label\": \"work\"}],\n [\"...\", \"${DeprovisionWorkQueue.QueueName}\", {\"label\": \"deprovision-work\"}],\n [\"...\", \"${MeteringCleanupQueue.QueueName}\", {\"label\": \"cleanup\"}]\n ]\n }\n },\n {\n \"type\": \"metric\", \"x\": 0, \"y\": 19, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"Client-side rejections by reason (UsageRecordRejected)\",\n \"region\": \"${AWS::Region}\", \"period\": 3600, \"view\": \"timeSeries\",\n \"metrics\": [\n [ { \"expression\": \"SELECT SUM(UsageRecordRejected) FROM \\\"AwsMarketplace/Metering\\\" WHERE ProductCode = '${ProductCode}' GROUP BY Reason ORDER BY SUM() DESC\", \"label\": \"by reason\", \"id\": \"q1\", \"period\": 3600 } ]\n ],\n \"yAxis\": {\"left\": {\"min\": 0}}\n }\n },\n {\n \"type\": \"metric\", \"x\": 12, \"y\": 19, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"Terminal statuses (submit outcomes + expiry by stage)\",\n \"region\": \"${AWS::Region}\", \"stat\": \"Sum\", \"period\": 3600,\n \"metrics\": [\n [\"AwsMarketplace/Metering\", \"CustomerNotSubscribed\", \"ProductCode\", \"${ProductCode}\", {\"label\": \"CustomerNotSubscribed\"}],\n [\"AwsMarketplace/Metering\", \"UsageAggregationExpired\", \"ProductCode\", \"${ProductCode}\", {\"label\": \"AggregationExpired (raw, never aggregated)\"}],\n [\"AwsMarketplace/Metering\", \"UsageSubmissionExpired\", \"ProductCode\", \"${ProductCode}\", {\"label\": \"SubmissionExpired (aggregated, never submitted)\"}],\n [\"AwsMarketplace/Metering\", \"DuplicateRecord\", \"ProductCode\", \"${ProductCode}\", {\"label\": \"DuplicateRecord\"}],\n [\"AwsMarketplace/Metering\", \"UsageRecordUnprocessed\", \"ProductCode\", \"${ProductCode}\", {\"label\": \"UnprocessedAfterRetry\"}]\n ]\n }\n },\n {\n \"type\": \"metric\", \"x\": 0, \"y\": 25, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"BatchMeterUsage exceptions by type (request-level, usage NOT billed)\",\n \"region\": \"${AWS::Region}\", \"period\": 3600, \"view\": \"timeSeries\",\n \"metrics\": [\n [ { \"expression\": \"SELECT SUM(BatchMeterUsageException) FROM \\\"AwsMarketplace/Metering\\\" WHERE ProductCode = '${ProductCode}' GROUP BY \\\"Exception\\\" ORDER BY SUM() DESC\", \"label\": \"by exception\", \"id\": \"x1\", \"period\": 3600 } ]\n ],\n \"yAxis\": {\"left\": {\"min\": 0}}\n }\n }\n ]\n }\n\n # Direct-submit health dashboard: the seller writes finalized records straight to\n # aggregated_usage, so only the submitter + submission-expiry exist. No discoverer/\n # aggregator/cleanup, no work/cleanup queues, no aggregation-stage metrics.\n MeteringHealthDashboardDirect:\n Type: AWS::CloudWatch::Dashboard\n Condition: CreateDirectDashboard\n Properties:\n # Account-global name; keep the Region so the same StackPrefix in another\n # Region does not collide. (Full vs direct are mutually exclusive by\n # Condition, so they intentionally share the same per-Region name.)\n DashboardName: !Sub ${StackPrefix}-metering-health-${AWS::Region}\n DashboardBody: !Sub |\n {\n \"widgets\": [\n {\n \"type\": \"metric\", \"x\": 0, \"y\": 0, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"Submitter / submission-expiry Lambda health\",\n \"region\": \"${AWS::Region}\", \"stat\": \"Sum\", \"period\": 300,\n \"metrics\": [\n [\"AWS/Lambda\", \"Errors\", \"FunctionName\", \"${SubmitterFunction}\", {\"label\": \"submitter errors\"}],\n [\"AWS/Lambda\", \"Throttles\", \"FunctionName\", \"${SubmitterFunction}\", {\"label\": \"submitter throttles\"}],\n [\"AWS/Lambda\", \"Invocations\", \"FunctionName\", \"${SubmitterFunction}\", {\"label\": \"submitter invocations\"}],\n [\"AWS/Lambda\", \"Errors\", \"FunctionName\", \"${SubmissionExpiryFunction}\", {\"label\": \"expiry errors\"}]\n ]\n }\n },\n {\n \"type\": \"metric\", \"x\": 12, \"y\": 0, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"Terminal statuses (submit outcomes + submission-expiry)\",\n \"region\": \"${AWS::Region}\", \"stat\": \"Sum\", \"period\": 3600,\n \"metrics\": [\n [\"AwsMarketplace/Metering\", \"CustomerNotSubscribed\", \"ProductCode\", \"${ProductCode}\", {\"label\": \"CustomerNotSubscribed\"}],\n [\"AwsMarketplace/Metering\", \"DuplicateRecord\", \"ProductCode\", \"${ProductCode}\", {\"label\": \"DuplicateRecord\"}],\n [\"AwsMarketplace/Metering\", \"UsageSubmissionExpired\", \"ProductCode\", \"${ProductCode}\", {\"label\": \"SubmissionExpired (never submitted)\"}],\n [\"AwsMarketplace/Metering\", \"UsageRecordUnprocessed\", \"ProductCode\", \"${ProductCode}\", {\"label\": \"UnprocessedAfterRetry\"}]\n ]\n }\n },\n {\n \"type\": \"metric\", \"x\": 0, \"y\": 6, \"width\": 12, \"height\": 6,\n \"properties\": {\n \"title\": \"BatchMeterUsage exceptions by type (request-level, usage NOT billed)\",\n \"region\": \"${AWS::Region}\", \"period\": 3600, \"view\": \"timeSeries\",\n \"metrics\": [\n [ { \"expression\": \"SELECT SUM(BatchMeterUsageException) FROM \\\"AwsMarketplace/Metering\\\" WHERE ProductCode = '${ProductCode}' GROUP BY \\\"Exception\\\" ORDER BY SUM() DESC\", \"label\": \"by exception\", \"id\": \"x1\", \"period\": 3600 } ]\n ],\n \"yAxis\": {\"left\": {\"min\": 0}}\n }\n }\n ]\n }\n\nOutputs:\n RegistrationUrl:\n Description: Set this as your SaaS fulfillment URL in AWS Marketplace Management Portal\n Value: !Sub https://${RegistrationApi}.execute-api.${AWS::Region}.amazonaws.com/${StageName}/register\n UsageTableName:\n Condition: IsFullPipeline\n Value: !Ref UsageTable\n AggregatedUsageTableName:\n Value: !Ref AggregatedUsageTable\n DiscovererFunctionName:\n Condition: IsFullPipeline\n Value: !Ref DiscovererFunction\n AggregatorFunctionName:\n Condition: IsFullPipeline\n Value: !Ref AggregatorFunction\n CleanupFunctionName:\n Condition: IsFullPipeline\n Value: !Ref CleanupFunction\n SubmitterFunctionName:\n Value: !Ref SubmitterFunction\n SubmissionExpiryFunctionName:\n Value: !Ref SubmissionExpiryFunction","content_type":"application/yaml; charset=utf-8","language":"yaml","size":84217,"content_sha256":"a1d2637578ca607abf718ae0012f85d33c2a74d38d457aabdcd1d9d15ba5cdb9"},{"filename":"references/architecture.md","content":"## Architecture\n\n```\n us-east-1 seller region (e.g. us-west-2)\n ┌─────────────────────┐ ┌──────────────────────────────────┐\n │ EventBridge Rule │ │ API Gateway │\nMarketplace ──────► │ (aws.agreement- │ │ POST /register │\nAgreement + │ marketplace) │ │ │ │\nLicense Events │ │ │ │ ▼ │\n │ ▼ │ │ Register Lambda │\n │ SQS Queue ──► DLQ │ │ (ResolveCustomer) │\n │ │ │ │ │ │\n │ ▼ │ read+write │ └──► updates subscribers ──┤\n │ Subscription Lambda │◄─────────────┼────────────(cross-region) │\n │ │ │ │ │\n │ ▼ │ │ EventBridge Schedule (rate 15m) │\n │ DynamoDB │ │ │ │\n │ (awsmp-subscribers) │◄─────────────┼── Metering pipeline reads subs │\n │ PK: licenseArn │ read │ ▼ │\n │ SK: customerAWSAcct │ │ discoverer→aggregator→cleanup │\n │ productCode, │ │ →submitter (→ BatchMeterUsage) │\n │ agreementStatus, │ │ │\n │ subscriptionStatus, │ │ DynamoDB (usage) │\n │ registeredRegions │ │ PK: licenseArn │\n └─────────────────────┘ │ SK: custAcct#dimension#ts │\n └──────────────────────────────────┘\n```\n\n### Data Model\n\n**Unified Subscribers Table** — a SINGLE table holding subscription/agreement STATE, **PII-FREE**. Lives in `us-east-1` alongside EventBridge. Populated by EventBridge events and updated by the register Lambda (identity + statuses + `registeredRegions` only). Buyer registration-form data (PII) is NOT stored here — it lives in the per-Region `customer-profile` table (below).\n\nFor **Concurrent Agreements (default, new products):** `licenseArn` is the PARTITION key, `customerAWSAccountId` is the sort key. A buyer with multiple concurrent agreements has multiple rows (same `customerAWSAccountId`, different `licenseArn`), tracked independently.\n\nFor **legacy (pre-CA) products:** key by `customerAWSAccountId` alone.\n\n| Field | Source | Description |\n|-------|--------|-------------|\n| `licenseArn` (PK, CA) | License Updated event: `detail.license.arn` | License ARN — partition key for CA products |\n| `customerAWSAccountId` (SK) | Agreement events: `detail.acceptor.accountId` | Buyer's AWS account ID |\n| `productCode` | License Updated event: `detail.product.code` | Metering product code |\n| `agreementId` | Agreement events: `detail.agreement.id` | Unique agreement identifier |\n| `agreementStatus` | Set by subscription Lambda | `active` (License Updated) / `inactive` (Purchase Agreement Ended) — the agreement lifecycle |\n| `subscriptionStatus` | Set by subscription Lambda (transitions) + deprovision-cleanup Lambda (finalization) | `active` (License Updated) → `deprovisioning` (License Deprovisioned — final-usage flush window open) → `inactive` (deprovision-cleanup Lambda, once `deprovisioningExpiry` passes). There is no `deprovisioned` value. |\n| `registeredRegions` | Appended by the register Lambda | DynamoDB String Set (SS) of AWS Region names where the buyer was registered (added idempotently via `ADD`, so no duplicates). Reference-only; NEVER gates metering |\n| `customerIdentifier` | ResolveCustomer response (legacy only) | Deprecated; persisted only for legacy (pre-CA) support, NOT for new integrations |\n\n> **The subscribers table is PII-FREE.** It carries only the CA identity (`licenseArn`, `customerAWSAccountId`, `productCode`, `agreementId`), the two lifecycle statuses, and `registeredRegions`. Buyer registration-form data (PII — contact name, email, company, KYB, seller-defined fields) is NOT stored here; it lives in the **per-Region `customer-profile` table** (below), written in-region by the register Lambda. Because it holds no PII, the subscribers table is safe in `us-east-1` even when the product/buyers operate in other (opt-in) Regions.\n\n**Customer-profile table (per Region)** — `awsmp-\u003cproductCode>-customer-profile`, keyed by `licenseArn` (PK) + `customerAWSAccountId` (SK), deployed IN EACH metering/registration Region. Holds the seller's allowlisted registration/KYB fields (buyer PII), with additional seller-requested GSIs on those fields. Written by the in-region register Lambda; kept in-region so buyer PII is never replicated to us-east-1. Not on the metering path.\n\n> **Mapping a `licenseArn` to the seller's internal SaaS tenant ID is the SELLER's responsibility — this skill does not own the tenant model.** AWS Marketplace identifies a buyer by `licenseArn` (+ `customerAWSAccountId`); how that maps to the seller's own tenant/account/org ID is the seller's application concern. The skill provides TWO places the seller can anchor that mapping, and the seller CHOOSES which (or both):\n>\n> - **Subscribers table** (us-east-1) — carries the `licenseArn` from the **`License Updated`** EventBridge event (the authoritative, refreshed-on-renewal source; PII-free, so store only a non-PII internal tenant identifier here if you add one).\n> - **Customer-profile table** (in-Region) — carries the `licenseArn` captured at **registration time** (when the buyer clicks \"Set up your account\" and `ResolveCustomer` runs), alongside the buyer's registration/KYB fields — a natural home if the tenant record is created from the registration form.\n>\n> A row may exist in one before the other (registration and `License Updated` can arrive in either order), so a seller that needs the mapping available from the earliest moment often writes it in BOTH. The seller adds their own `tenantId` (or equivalent) attribute — the skill neither invents nor requires a specific tenant-ID scheme — and, if they need to look a tenant up by that ID, defines a GSI on it (on the customer-profile table for a PII/registration-derived ID, or on the subscribers table for a non-PII one).\n\n**Usage Table** — keyed by `licenseArn` (PK) + `customerAWSAccountId#dimension#timestamp` (SK, **second precision**). Keying by licenseArn (not account) ensures usage is attributed per-agreement, preventing double-billing when a buyer holds multiple concurrent licenses. `customerAWSAccountId`, `dimension`, and `timestamp` are also stored as top-level attributes so the pipeline reads them without parsing the sort key. The seller writes usage here (and sets `meteringPending`); the metering pipeline reads it and calls BatchMeterUsage.\n\n### Event Flow (Order of Operations)\n\n```\n1. Buyer clicks \"Subscribe\"\n → EventBridge: Purchase Agreement Created (us-east-1)\n → NOT consumed by the metering path. It carries no licenseArn and is not needed\n to meter usage. Sellers MAY add their own rule/target for other use cases\n (see \"Purchase Agreement Created — seller use cases\" below).\n\n2. Shortly after (~seconds)\n → EventBridge: License Updated (us-east-1)\n → Subscription Lambda UPSERTS the subscriber record (us-east-1), keyed by the real\n licenseArn (detail.license.arn) + customerAWSAccountId, with productCode\n (detail.product.code) and subscriptionStatus=active. This is the row that drives\n metering — there is no separate \"pending\" placeholder row.\n\n3. Buyer clicks \"Set up your account\"\n → Marketplace redirects the buyer's browser (with x-amzn-marketplace-token) to the\n Fulfillment URL = the branded registration PAGE (S3+CloudFront); the page renders,\n captures extra buyer/KYB fields, then POSTs the token + fields to the RegistrationUrl\n backend (a raw RegistrationUrl Fulfillment URL is a bare-bones fallback only)\n → Register Lambda calls ResolveCustomer (this skill deploys the register Lambda in the\n main-stack region as a packaging choice; ResolveCustomer's region is the seller's free\n choice and does NOT gate metering or have to match the BatchMeterUsage region)\n → Gets: CustomerAWSAccountId, LicenseArn, ProductCode\n → Writes buyer PII (allowlisted registration/KYB fields) to the IN-REGION\n customer-profile table keyed by licenseArn + customerAWSAccountId; upserts the PII-FREE\n subscriber row (identity + statuses) and appends the invocation Region to\n registeredRegions (idempotent, PII-free metadata write to us-east-1). Registration\n never gates metering.\n\n4. Every 15 minutes (metering pipeline; NOT a single hourly Meter Lambda)\n → The discoverer (rate(15m)) queries the metering_pending GSI for each completed hour\n bucket and enqueues one work message per (licenseArn, account, dimension, hour) group;\n the aggregator sums the group's rows and conditionally writes one aggregated_usage\n record; cleanup clears the raw rows' meteringPending; the submitter (rate(5m)) drains\n aggregated_usage and calls BatchMeterUsage. Driven by the metering_pending GSI, not by\n registration state.\n → Calls BatchMeterUsage with CustomerAWSAccountId + LicenseArn per record\n\n5. Buyer cancels / agreement expires\n → EventBridge: Purchase Agreement Ended (us-east-1)\n → Subscription Lambda sets agreementStatus=inactive ONLY. This does NOT change\n subscriptionStatus, stop metering, or trigger a flush — the buyer may still be\n entitled to usage until the license is deprovisioned.\n\n6. License Deprovisioned (us-east-1)\n → Subscription Lambda sets subscriptionStatus=deprovisioning + a deprovisioningExpiry\n (event time + ~1h). This OPENS the ~1-hour final-usage flush window: the pipeline\n flushes any remaining usage, and the events-stack deprovision-cleanup Lambda (rate(15m))\n later sets subscriptionStatus=inactive once deprovisioningExpiry has passed. Once the\n server-side window closes, BatchMeterUsage returns CustomerNotSubscribed.\n```\n\n> **Status lifecycle:** agreementStatus: `active` (License Updated) → `inactive`\n> (Purchase Agreement Ended). subscriptionStatus: `active` (License Updated) →\n> `deprovisioning` (License Deprovisioned opens the ~1-hour flush window) → `inactive`\n> (deprovision-cleanup Lambda, once `deprovisioningExpiry` passes). The two move independently and are never\n> conflated. Metering decisions follow subscriptionStatus / the metering_pending GSI, not\n> agreementStatus or registeredRegions.\n\n### Purchase Agreement Created — seller use cases\n\nThe metering path does **not** consume `Purchase Agreement Created`, but it is a useful\nsignal for other seller workflows. Per the AWS Marketplace SaaS EventBridge integration,\nthis event fires when *a new agreement is created, an existing agreement is replaced, or an\nexisting agreement is renewed*, and is delivered to both the manufacturer and proposer\nroles. Sellers commonly use it to:\n\n- **Post-sale onboarding / provisioning** — kick off tenant provisioning, welcome emails,\n or CRM/opportunity updates as soon as a buyer subscribes (before the license arrives).\n- **Free-trial detection** — call `DescribeAgreement` to determine whether the new\n agreement is a free trial and branch onboarding accordingly.\n- **Renewal / replacement tracking** — because the event also fires on replace/renew, use\n `agreement.id` (and any prior-agreement linkage) to track renewals and upgrades.\n- **CPPO awareness** — the manufacturer vs proposer variants differ by the presence of a\n `resaleAuthorization` ID; use this to distinguish direct vs channel-partner resale deals.\n\nTo act on it, add an EventBridge rule that matches\n`detail-type: \"Purchase Agreement Created - Manufacturer\"` (and/or `- Proposer`) with\n`source: \"aws.agreement-marketplace\"` and route it to your own target — do not add it to the\nmetering Subscription Lambda. References:\n\n- [Managing SaaS subscription events with Amazon EventBridge](https://docs.aws.amazon.com/marketplace/latest/userguide/saas-eventbridge-integration.html)\n- [When a SaaS subscription ends / is cancelled](https://docs.aws.amazon.com/marketplace/latest/userguide/saas-subscriptions.html)\n\n### Deprovisioning — the seller's own tenant-teardown workflow\n\nThe deployed pipeline handles the metering side of deprovisioning automatically — on `License\nDeprovisioned` it flushes the license's remaining usage within the ~1-hour window (bypassing\n`MeteringLockHours`) and the events-stack `deprovision-cleanup` Lambda finalizes\n`subscriptionStatus = inactive` once `deprovisioningExpiry` passes. But **tearing down the\nseller's OWN tenant** (revoking the buyer's access, releasing per-tenant resources, CRM/billing\nupdates) is the SELLER's application responsibility — the skill does not own the tenant model\n(mapping `licenseArn`/`customerAWSAccountId` → the seller's internal tenant ID is the seller's,\nsee the tenant-mapping note above). There are TWO supported ways for the seller to react, and\nthe seller may choose either or both:\n\n1. **Listen to the Marketplace deprovisioning EventBridge event DIRECTLY.** Add your OWN\n EventBridge rule in `us-east-1` matching `source: \"aws.agreement-marketplace\"` /\n `detail-type: \"License Deprovisioned\"` (and/or `\"Purchase Agreement Ended\"`), routed to your\n own target (Lambda/SQS/Step Functions) that begins tenant teardown. This is INDEPENDENT of\n the metering Subscription Lambda — add your own rule/target; **do NOT** bolt teardown logic\n onto the metering path (same pattern as the `Purchase Agreement Created` use cases above).\n *Trade-off:* reacts fastest, closest to the source (can act before the flush window even\n matters), but you own event-shape handling and idempotency.\n2. **Consume the subscribers-table DynamoDB Stream.** The events stack ENABLES a DynamoDB Stream\n (`NEW_AND_OLD_IMAGES`) on the PII-free `us-east-1` subscribers table and publishes its ARN as\n the `SubscribersTableStreamArn` stack output; attach your OWN consumer (a Lambda event source\n mapping, Kinesis adapter, etc.) and react when a row transitions to\n `subscriptionStatus = deprovisioning` (and later `inactive`). *Trade-off:* you react to the\n ALREADY-RECONCILED, de-duplicated, region-collapsed state the Subscription Lambda maintains\n (simpler, no event-shape parsing), but it is one hop behind the raw event. Natural when your\n tenant model is already keyed off the subscriber record.\n\nChoose based on how time-critical your teardown is relative to the ~1-hour flush window. Either\nway the metering flush/finalization is already handled by the deployed pipeline — these hooks\ndrive only the seller's SaaS-side actions.\n\n> **Key insight:** The subscribers table lives in `us-east-1` alongside EventBridge. When the metering region is a different region, the register Lambda and the metering pipeline (discoverer/submitter) read it cross-region. **If the seller's metering region is also `us-east-1`, the events stack and main stack are both in `us-east-1` and everything is single-region — no cross-region reads at all.** us-east-1 is a fully supported metering region for `BatchMeterUsage`.\n>\n> **Multi-region sellers:** Each metering region gets its own main stack (usage table + Lambdas). All regions share the single subscribers table in `us-east-1`. Deploy the events stack once (us-east-1), then deploy the main stack to each metering region — which MAY include us-east-1 itself.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":16599,"content_sha256":"9ece395637f67236145cecbce3011081d501f15c7ed4dddc55962f041f5f1d7f"},{"filename":"references/client-side-guardrails.md","content":"# Client-Side Guardrails (applied BEFORE `BatchMeterUsage`)\n\nThis is the authoritative list of everything the metering pipeline checks or enforces\n**client-side, before a record reaches the `BatchMeterUsage` service call.** The goal is\nthat a record only reaches the service if it will be accepted — a bad record is caught\nlocally, recorded with a specific reason, and never sent (so it neither wastes an API\ncall nor risks poisoning a batch).\n\nMost checks live in the **aggregator** and are *reject-and-finalize*: the group's rows get\na terminal `meteringStatus = RejectedClientSide` plus a specific reason, `meteringPending`\nis cleared, and one `UsageRecordRejected` metric (dimensioned by `Reason` + `ProductCode`)\nis emitted. Because a rejected row was never sent, its dedup grain\n(`CustomerAWSAccountId` + `LicenseArn` + `dimension` + `hour`) is untouched — **fix the\nsource row, re-write it within 24h, and it meters normally.**\n\n> The per-reason \"what to fix\" table lives in `troubleshooting.md`\n> (\"Client-side rejection reason codes\"). This document is the COMPLETE guardrail\n> inventory including the timing, batch-shaping, and idempotency guardrails that are not\n> reason codes.\n\n## Raw-row `meteringStatus` domain\n\nEvery raw usage row that the pipeline finishes with reaches exactly one terminal\n`meteringStatus`, so its fate is always observable on the row itself:\n\n| `meteringStatus` | Set by | `meteringStatusReason` | Meaning |\n|------------------|--------|------------------------|---------|\n| `Aggregated` | cleanup Lambda | `Aggregated total quantity \u003csum> from \u003ccount> raw usage records` | Row was folded into its group's aggregated record (the happy path). |\n| `RejectedClientSide` | aggregator | the specific reason code (see below) | Row's group failed a client-side guardrail and was never sent. |\n| `AggregationExpired` | discoverer (age-out) | — | Row aged past the 24h window BEFORE it could be aggregated (lost before aggregation). |\n\n> This is the RAW table's domain. The `awsmp-aggregated-usage` table has its OWN, disjoint status domain for the SUBMISSION stage — `Success` / `DuplicateRecord` / `CustomerNotSubscribed` (the per-record `BatchMeterUsage` `Results` statuses) / `Error` (a request-level SERVER-SIDE `BatchMeterUsage` exception isolated to the record — the exception name, e.g. `InvalidUsageDimensionException` or `InvalidLicenseException`, is kept in `meteringStatusReason`; NOT a per-record status and NOT `RejectedClientSide`) / `SubmissionExpired` (aggregated but not submitted before the window/grace closed). **`RejectedClientSide` NEVER appears on the aggregated table** — it is a RAW-table-only status for a client-side writer-contract violation; a server-side submit failure is `Error` (fix the catalog dimension / license / offer, not the writer). \"Expired\" is never used un-qualified: raw = `AggregationExpired`, aggregated = `SubmissionExpired`.\n\nThe SUBMISSION outcome (`meteringRecordId`, and the `Success`/`CustomerNotSubscribed`/…\n`BatchMeterUsage` status) is NOT written to the raw row — it lives on the\n`aggregated_usage` record. A row still carrying `meteringPending` (no `meteringStatus`) is\nsimply awaiting processing.\n\n## A. Timing / window guardrails\n\n| Guardrail | Behavior |\n|-----------|----------|\n| Never meter the in-progress hour | Only completed hour buckets `now-23h … now-1h` are processed, oldest-first. A record is never built for an hour that is still accumulating. |\n| Metering lock period | An hour is not aggregated/submitted until it has been closed for `MeteringLockHours` hours (default 1, configurable 1–20). Late usage has that long to land before the hour is submitted. |\n| Month-boundary reconciliation | Previous-month hours keep the configured lock until 03:00 UTC on the 1st, then submit immediately (no lock) until the 06:00 UTC month-end cutoff, and are not aged out before then. A previous-month record written after 03:00 UTC on the 1st must already be pre-aggregated by the seller (else `DuplicateRecord`). |\n| `TimestampInFuture` | A group whose hour is in the future is rejected client-side. |\n| `TimestampOutOfWindow` | A group whose hour is older than the 24h acceptance window is rejected client-side — the service would otherwise reject the ENTIRE batch, so one stale record can't poison others. |\n| `MalformedTimestamp` | A raw-usage row whose sort-key suffix is not an exact second-precision `YYYY-MM-DDTHH:MM:SS` timestamp (e.g. millisecond/fractional or hour-truncated) is rejected — enforces the second-precision writer contract at aggregation. |\n| `MalformedSortKey` | A raw-usage row whose sort key is missing or has fewer than the three `account#dimension#timestamp` segments is rejected. |\n| `SortKeyMismatch` | A raw-usage row whose sort-key account/dimension segments do not match the row's own attributes (a corrupt/mis-keyed row) is rejected. |\n| Age-out before submit | Raw rows past 24h that were never aggregated are terminally expired (`meteringStatus = AggregationExpired`, `UsageAggregationExpired` metric) and never enter a batch; an aggregated record that was never submitted in time is expired on the aggregated table (`SubmissionExpired`, `UsageSubmissionExpired` metric). |\n\n## B. Quantity guardrails (strict — never coerced)\n\n| Guardrail (reason code) | Behavior |\n|-------------------------|----------|\n| `NonNumericQuantity` | A `quantity` not parseable as a number is rejected. |\n| `NonIntegerQuantity` | A fractional/decimal quantity (e.g. `2.5`) is rejected — NEVER silently truncated or floored. |\n| `NegativeQuantity` | A negative quantity is rejected. |\n| (booleans) | A boolean quantity is rejected (not treated as `0`/`1`). |\n| Zero handling | `0` is NOT a rejection. An in-window zero group is left pending (submitting `0` early would lock the hour at `0` via first-write-wins dedup); it is submitted as `Quantity: 0` only at the oldest processed edge. |\n\n## C. Dimension guardrails\n\n| Guardrail (reason code) | Behavior |\n|-------------------------|----------|\n| `MissingDimension` | A group with no dimension is rejected (structural: a dimension key must be present). |\n\n> Dimension NAME validity is **not** checked client-side. `BatchMeterUsage` is the authority — an undefined pricing dimension fails the call server-side with a request-level `InvalidUsageDimensionException` at submit time (NOT a per-record status; the submitter isolates the offending record to `RejectedClientSide` with the exception name as reason, surfaced via the `BatchMeterUsageException` metric/alarm). This keeps the pipeline decoupled from the catalog, so adding or renaming a pricing dimension needs no redeploy.\n\n## D. Identifier guardrails\n\n| Guardrail (reason code) | Behavior |\n|-------------------------|----------|\n| `InvalidLicenseArn` | The LicenseArn is shape-validated against a License Manager ARN pattern BEFORE batching, so a malformed ARN never enters a batch (a bad ARN is a request-level error that would sink co-batched records). |\n| `MissingCustomerAWSAccountId` | A Concurrent-Agreements (ARN-keyed) group with no account ID is rejected. |\n| `MissingLegacyIdentifier` | A legacy (non-ARN) group with neither `CustomerIdentifier` nor `CustomerAWSAccountId` is rejected. |\n\n## E. VMT / `UsageAllocations` guardrails (merged, all-or-nothing)\n\n| Guardrail (reason code) | Behavior |\n|-------------------------|----------|\n| `MalformedAllocations` | `usageAllocations` is not a list, or an entry is not an object. |\n| `NegativeOrNonIntegerAllocation` | An allocation quantity is negative or non-integer (same strict-int rule as quantity). |\n| `MissingTagKeyOrValue` | An allocation has no tags, or a tag is missing a non-empty Key/Value. |\n| `TooManyTags` | An allocation has more than 5 tags (service per-allocation cap). |\n| `TooManyAllocations` | The merged group produced more than 2500 distinct tag-set allocations (service per-record cap). |\n| `MixedAllocatedAndUnallocated` | Within one group, some non-zero rows carry allocations and some don't — must be all-tagged or all-untagged. |\n| `AllocationSumMismatch` | The merged allocation quantities do not sum to the record `Quantity`. |\n| tags carried through | The merge only SUMS the seller's own tag sets; the pipeline never invents or re-partitions the split. |\n\n## F. Batch-shaping / submit-time guardrails\n\n| Guardrail | Behavior |\n|-----------|----------|\n| ≤25 records per call | Records are chunked to the `BatchMeterUsage` hard limit. |\n| ≤1 MB request payload | Independent of the record count: even a ≤25-record batch is rejected for SIZE if the serialized request exceeds 1 MB (large/many `usageAllocations` tag-sets, long ARNs). Chunk into fewer records per call so each request stays under 1 MB — it is the byte size, not the count. |\n| CA vs legacy split | Concurrent-Agreements records (LicenseArn) and legacy records (ProductCode) go in separate batches — the two paths pass identifiers differently. |\n| Request-level poison isolation | If a batch is rejected with a non-transient request-level error, the batch is recursively bisected (bounded to ⌈log2(batch size)⌉ = 5 levels, so a single poison record can always be isolated from a full 25-record batch) so a single poison record is isolated and terminally recorded while the rest still meter; at the depth cap a still-failing multi-record sub-batch is left pending for the next run (a `BisectDepthExceeded` metric fires) so a many-failure batch can't overload the serial submitter. |\n| Retry-once on unprocessed | Transient `UnprocessedRecords` are retried once; any still-unprocessed are left pending (re-metered next run) and the invocation fails so the error alarm fires. |\n| Transient vs terminal | Only throttling / internal-service / service-unavailable errors are retried; every other error is terminal for that record. |\n\n## G. Idempotency / anti-double-billing guardrails\n\n| Guardrail | Behavior |\n|-----------|----------|\n| Conditional write commit point | A group is written to the aggregated-usage table at most once (conditional put); a duplicate visit no-ops, so re-discovery can't submit the same group twice. |\n| Second-precision dedup grain | The sort key bounds a group to one hour and de-duplicates the discovery tuples. |\n| First-write-wins awareness | The pipeline deliberately avoids submitting `0` early because the service dedups per `(CustomerAWSAccountId + LicenseArn + dimension + hour)` and a premature `0` would lock the hour. |\n\n## Why client-side first\n\nEvery guardrail above means a defective record is caught, reason-coded, and left\nre-writable within the 24h window **without ever calling the service** — avoiding wasted\nAPI calls, avoiding a single bad record rejecting a whole batch, and avoiding both\ndouble-billing and silent under-billing. Anything that reaches `BatchMeterUsage` has\nalready passed all of these.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10865,"content_sha256":"11f414ba56c223d9683062caebd6e87f145a9ec9b765dfaa224b69ce70b79525"},{"filename":"references/concurrent-agreements.md","content":"## Concurrent Agreements Integration\n\n> **This section is critical. Read it fully before generating any code.**\n\nAWS Marketplace introduced **Concurrent Agreements** to allow buyers to make multiple purchases of the same product on a single AWS account. This fundamentally changes which fields are used for metering.\n\n### What Changed\n\n| Field | Old Integration (Legacy) | New Integration (Concurrent Agreements — required for new products) |\n|-------|--------------------------|---------------------------------------------------------------|\n| **Customer identity in UsageRecord** | `CustomerIdentifier` (opaque string) | `CustomerAWSAccountId` (AWS account ID) |\n| **Product/agreement identity** | `ProductCode` (at request level) | `LicenseArn` (per UsageRecord) |\n| **Deduplication scope** | per (ProductCode + CustomerIdentifier + dimension + hour) | per (CustomerAWSAccountId + LicenseArn + dimension + hour) |\n\n### Correct BatchMeterUsage Payload\n\n```python\n# ✅ CORRECT — New integration (Concurrent Agreements)\nresponse = client.batch_meter_usage(\n UsageRecords=[\n {\n 'Timestamp': timestamp,\n 'CustomerAWSAccountId': '123456789012', # from ResolveCustomer\n 'LicenseArn': 'arn:aws:license-manager::123456789012:license/lic-xxx', # from ResolveCustomer\n 'Dimension': 'MyDimension',\n 'Quantity': 5,\n }\n ]\n # NOTE: No ProductCode at request level when using LicenseArn\n)\n```\n\n### Incorrect Patterns (Common AI-Generated Mistakes)\n\n```python\n# ❌ WRONG — Using CustomerIdentifier for metering\nresponse = client.batch_meter_usage(\n UsageRecords=[\n {\n 'Timestamp': timestamp,\n 'CustomerIdentifier': 'cust-example-id-123', # WRONG — don't use for new integrations\n 'Dimension': 'MyDimension',\n 'Quantity': 5,\n }\n ],\n ProductCode='abc123'\n)\n\n# ❌ WRONG — Mixing LicenseArn and ProductCode in the same request\nresponse = client.batch_meter_usage(\n UsageRecords=[\n {\n 'Timestamp': timestamp,\n 'CustomerAWSAccountId': '123456789012',\n 'LicenseArn': 'arn:aws:license-manager::123456789012:license/lic-xxx',\n 'Dimension': 'MyDimension',\n 'Quantity': 5,\n }\n ],\n ProductCode='abc123' # WRONG — don't include ProductCode when using LicenseArn\n)\n\n# ❌ WRONG — Using CustomerIdentifier with LicenseArn\nresponse = client.batch_meter_usage(\n UsageRecords=[\n {\n 'Timestamp': timestamp,\n 'CustomerIdentifier': 'cust-example-id-123', # WRONG — use CustomerAWSAccountId\n 'LicenseArn': 'arn:aws:license-manager::123456789012:license/lic-xxx',\n 'Dimension': 'MyDimension',\n 'Quantity': 5,\n }\n ]\n)\n```\n\n### ResolveCustomer Response — What to Store\n\n`ResolveCustomer` returns `LicenseArn`, `CustomerAWSAccountId`, and `ProductCode` (plus a\nlegacy, now-deprecated `CustomerIdentifier` that is null for new integrations). Store the first\nthree, and use `LicenseArn` + `CustomerAWSAccountId` for metering:\n\n```python\nresult = client.resolve_customer(RegistrationToken=token)\n\n# ResolveCustomer returns a TOP-LEVEL LicenseArn (plus CustomerAWSAccountId + ProductCode):\nlicense_arn = result['LicenseArn'] # ✅ top-level field, e.g. arn:aws:license-manager::...\ncustomer_aws_account_id = result['CustomerAWSAccountId'] # ✅ USE THIS (with LicenseArn) for metering\nproduct_code = result['ProductCode'] # Store for validation only, NOT for metering\n# result['CustomerIdentifier'] is DEPRECATED for new SaaS integrations and is null/absent —\n# do NOT rely on it, and do NOT treat it as the LicenseArn. It is legacy-only (pre-CA).\n```\n\n> **Key Fact — ResolveCustomer returns LicenseArn directly:** `ResolveCustomer` returns a **top-level `LicenseArn`** field along with `CustomerAWSAccountId` and `ProductCode`. For NEW (Concurrent Agreements) integrations, `CustomerIdentifier` is **deprecated and null/absent** — do NOT use it and do NOT treat its value as the LicenseArn. Persist `LicenseArn` + `CustomerAWSAccountId` from the ResolveCustomer response; both are also reaffirmed by the `License Updated` EventBridge event (a row can alternatively be created from that event when a seller never calls ResolveCustomer). Downstream (BatchMeterUsage, entitlement checks) require `LicenseArn` + `CustomerAWSAccountId`.\n>\n> **Key Fact:** ResolveCustomer succeeding only means the registration token is valid — it does NOT confirm the subscription is active. Wait for the EventBridge `License Updated` / subscribe-succeed event before starting to meter.\n\n### Unified Subscribers Table (DynamoDB schema)\n\nStore subscribers in a SINGLE unified table (not separate customer-profiles + subscriptions tables). The primary key depends on the integration pattern:\n\n| Pattern | Partition Key (PK) | Sort Key (SK) | Why |\n|---------|-------------------|---------------|-----|\n| **Concurrent Agreements (default, new products)** | `licenseArn` | `customerAWSAccountId` | One buyer can hold MULTIPLE active licenses for the same product; keying on `licenseArn` tracks each agreement independently and prevents double-billing |\n| **Legacy (pre-CA, grandfathered)** | `customerAWSAccountId` | — | One buyer = one subscription; no concurrent agreements |\n\nUnified table attributes (PII-FREE): `licenseArn` (PK), `customerAWSAccountId` (SK), `productCode`, `agreementId`, `agreementStatus` (active / inactive — agreement lifecycle), `subscriptionStatus` (active / deprovisioning / inactive — license lifecycle; there is no `deprovisioned` value), and `registeredRegions` (a DynamoDB String Set (SS) of AWS Region names where the buyer was registered, added idempotently via `ADD` — reference only, never gates metering). It does NOT carry `customerIdentifier` for a new CA integration (deprecated, legacy-only) and does NOT carry buyer PII / registration-form data — that lives in the per-Region `customer-profile` table (keyed `licenseArn` PK + `customerAWSAccountId` SK, seller GSIs), written in-region by the register Lambda. Keeping the subscribers table PII-free is why it can safely live in us-east-1 even for opt-in-Region products. The subscribers table defines two GSIs so lookups never need a `Scan`: `customerAWSAccountId-index` (register lookup / all agreements for one buyer) and `agreementId-index` (subscription lookup when an event omits `license.arn`).\n\n> **Critical:** For CA products, `licenseArn` MUST be the partition key — NOT `customerAWSAccountId` and NOT `licenseArn` as a sort key. A buyer with two concurrent agreements has two rows (same `customerAWSAccountId`, different `licenseArn`). Keying by account alone would collapse them and cause double-billing or lost usage. The usage table is likewise keyed by `licenseArn` (PK) + `customerAWSAccountId#dimension#timestamp` (SK) so usage is attributed per-agreement. This unified single-table design aligns with the **AWS Marketplace Serverless SaaS Integration reference architecture**. Metering is driven by the usage table's `metering_pending` GSI (not by subscriber/registration state): the meter Lambda queries that GSI for licenseArns with pending usage, builds each UsageRecord entirely from the usage rows (which carry `customerAWSAccountId`), and only looks up the subscriber row to read/update `subscriptionStatus`. Usage rows are keyed by the real `licenseArn` — there are no `pending:`-prefixed placeholder rows.\n\nThe table lives in `us-east-1` alongside the EventBridge events (that is the only region where marketplace lifecycle events are emitted).\n\n### Key Rules\n\n1. **New products:** MUST use `CustomerAWSAccountId` + `LicenseArn` per UsageRecord. Do NOT include `ProductCode` at the request level.\n2. **Existing (legacy) products:** Can continue using `CustomerIdentifier` + `ProductCode`, but should migrate to the new pattern.\n3. **Never mix:** Do NOT send `LicenseArn` and `ProductCode` for the same customer in the same hour. This causes **duplicate billing**.\n4. **`CustomerIdentifier` is NOT for metering:** It is an opaque identifier for customer lookup. Use `CustomerAWSAccountId` in UsageRecords.\n5. **`LicenseArn` is per-agreement:** A single buyer can have multiple `LicenseArn` values for the same product. Each represents a separate agreement with separate usage tracking.\n\n### Metering Cadence & Deduplication\n\n- **Call frequency:** Once per hour (use EventBridge `rate(1 hour)` schedule)\n- **Timestamp window:** Each UsageRecord timestamp must be within the **last 24 hours** of the event. Anything 24 hours or older is rejected with `TimestampOutOfBoundsException` (and one bad timestamp rejects the whole batch). A 6-hour month-boundary grace period applies: previous-month records are accepted until 06:00 UTC on the 1st of the next month.\n- **DuplicateRecord = first-write-wins and REPORTED (NOT simply \"benign\"):** If you resend a record for the same (CustomerAWSAccountId + LicenseArn + dimension + hour), the API returns that record with `Status = DuplicateRecord` (NOT `Success`, and present in the response — not silently absent). The FIRST submitted quantity stays final/billed; a resubmission with a DIFFERENT quantity is NOT billed. If a seller intended to correct 5→10, the correction is not applied and they UNDER-BILL by 5. Warn sellers: an unexpected DuplicateRecord may mean under-reporting. Aggregate usage BEFORE submitting so you never need to correct.\n- **Idempotent retries:** Retrying the SAME record/quantity on a transient failure is safe (returns DuplicateRecord, harmless). Never retry to change a quantity — that will not work.\n- **25-record batch limit + 1 MB request-size limit:** Each `BatchMeterUsage` call accepts max 25 UsageRecords **AND** the total request payload must be ≤ **1 MB**. These are two INDEPENDENT limits — a batch of ≤25 records can still be rejected for SIZE if the records are large (many/large `usageAllocations` tag-sets, long ARNs). If you have 25 valid records but the request is rejected for size, split into smaller batches (fewer records per call) so each request stays under 1 MB — it is the byte size, not the record count, that is over. **Rate limit: 10 `BatchMeterUsage` requests/second per account per region** (documented quota) — the serial submitter stays within it.\n\n### Why EventBridge (Not SNS) Is Required\n\nThe shift to `CustomerAWSAccountId` + `LicenseArn` also dictates which notification mechanism you must use:\n\n| Mechanism | Identifies customer by | Compatible with new integration? |\n|-----------|----------------------|----------------------------------|\n| **SNS** (legacy `aws-mp-subscription-notification-*`) | `CustomerIdentifier` only | ❌ No — SNS notifications do not include `CustomerAWSAccountId` |\n| **EventBridge** (`aws.agreement-marketplace`) | `CustomerAWSAccountId` (via agreement/acceptor details) | ✅ Yes |\n\n**The problem:** Legacy SNS subscription notifications (`subscribe-success`, `unsubscribe-success`, etc.) identify the customer using `CustomerIdentifier`. But since new integrations must key everything on `CustomerAWSAccountId`, you cannot reliably match an SNS notification to a customer record keyed by `CustomerAWSAccountId` without an extra lookup/mapping step.\n\n**EventBridge events** (`Purchase Agreement Created`, `Purchase Agreement Ended`, `License Deprovisioned`, etc.) include the buyer's AWS account ID in the event detail, making them directly compatible with `CustomerAWSAccountId`-based customer records.\n\n**Bottom line:** If you are building a new integration, use **EventBridge only**. Do not set up SNS subscription topics. This skill deploys an EventBridge-based events stack in `us-east-1` for this reason.\n\n> **Note for existing products migrating:** If you currently use SNS and are migrating to `CustomerAWSAccountId`-based metering, you must also migrate your subscription event handling from SNS to EventBridge. Running both during migration is safe — just ensure your customer table can be updated by either path.\n\n### Fallback for Legacy Products\n\n**The path is chosen by the seller's EXISTING integration, not by the `ResolveCustomer` response.** `ResolveCustomer` returns a top-level `LicenseArn` for SaaS products regardless of whether the integration is old or new, so its response is NOT the signal for which path to use.\n\n- **New integrations** always use the Concurrent Agreements pattern: `CustomerAWSAccountId` + `LicenseArn` per UsageRecord.\n- **Existing (legacy) integrations** that already meter with `ProductCode` + `CustomerIdentifier` can continue on that path. For an existing seller, the skill must understand the seller's current integration/code and support or migrate it — it does not force this skill's reference `register.py` / metering-pipeline handlers onto them.\n\nThe reference metering pipeline (`scripts/metering_core.py` + `scripts/submitter.py`) defaults to the CA (`LicenseArn`) path. A `ProductCode` fallback branch exists only to support a seller whose stored records predate CA (identified by a non-ARN `licenseArn` value), NOT because `ResolveCustomer` returned no LicenseArn:\n\n```python\nif str(customer.get('licenseArn', '')).startswith('arn:'):\n record['LicenseArn'] = customer['licenseArn']\n # No ProductCode at request level\nelse:\n # Legacy stored record (pre-CA): use ProductCode + CustomerIdentifier\n kwargs['ProductCode'] = PRODUCT_CODE\n```\n\n### Deprovisioning / Final Metering\n\nTwo independent status fields track different lifecycles and are NOT conflated:\n`agreementStatus` (agreement) and `subscriptionStatus` (license). Metering decisions follow\nthe license lifecycle only:\n\n1. **`Purchase Agreement Ended`** — set `agreementStatus = 'inactive'` ONLY. This is a\n status update; it does NOT change `subscriptionStatus`, stop metering, or trigger a\n flush. The buyer may still be entitled to usage until the license is deprovisioned.\n2. **`License Deprovisioned`** — set `subscriptionStatus = 'deprovisioning'` + a `deprovisioningExpiry`\n (event time + ~1h). This OPENS the ~1-hour final-usage flush window: submit any remaining usage\n IMMEDIATELY. The pipeline meters this row during the window; the events-stack deprovision-cleanup\n Lambda (`rate(15m)`) then sets `subscriptionStatus = 'inactive'` once `deprovisioningExpiry` has\n passed (time-based finalization — NOT the submitter). Once the server-side window closes,\n `BatchMeterUsage` returns `CustomerNotSubscribed` (a terminal status — do not retry).\n\n> **Do not invert these.** `License Deprovisioned` (not `Purchase Agreement Ended`) opens\n> the flush window. Do NOT stop metering or flush on `Purchase Agreement Ended`, and do NOT\n> discard queued records before flushing (that loses revenue). There is no `deprovisioned`\n> status value — the license lifecycle is `active` → `deprovisioning` → `inactive`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":14952,"content_sha256":"28bd139ae28e437b5e50518d07b4a926f1ceb1266f34ae553d4c27e3f17fe341"},{"filename":"references/deployment-steps.md","content":"### Step 1: Generate the Code\n\nGenerate these files using the templates in `references/`. Replace the placeholders with the user's actual values.\n\n> **Materialize, then deploy the same files (parity).** Write these artifacts to disk in the seller's workspace and deploy exactly those files — `deploy.sh` runs `sam build --template-file template.yaml` / `template-events.yaml` from the on-disk copies, so the deployed stack always matches the source. Do NOT deploy an inline or transient template that you do not also leave in the workspace. For a **modify/extend** case (an existing stack), edit the seller's existing on-disk files in place (merging environment variables) rather than scaffolding a fresh copy that diverges from what is deployed. Leave all artifacts in the workspace after deploying: they are the seller-owned source of truth, so the seller can version-control, re-deploy, test, and further develop them — customizing the marked `TODO:` sections (usage-write logic, VMT tags). `deploy.sh` fails fast (read-only) if the template it is about to build is missing or renamed.\n\n| File | Purpose | Template |\n|------|---------|----------|\n| `template.yaml` | Main SAM stack (API GW, DynamoDB, Register + metering-pipeline Lambdas) | `assets/template-main.yaml` |\n| `template-events.yaml` | Events stack (EventBridge rule, SQS, Subscription Lambda) | `assets/template-events.yaml` |\n| `src/handlers/register.py` | ResolveCustomer — buyer onboarding | `scripts/register.py` |\n| `src/handlers/metering_core.py` | Shared metering logic imported by the pipeline handlers (GSI discovery, targeted read, VMT merge, validation, BatchMeterUsage submit) | `scripts/metering_core.py` |\n| `src/handlers/discoverer.py` | discoverer — hourly, 30 invocations; enqueues per-group work + age-out | `scripts/discoverer.py` |\n| `src/handlers/aggregator.py` | aggregator — folds a group, validates, conditional-writes aggregated_usage, enqueues cleanup | `scripts/aggregator.py` |\n| `src/handlers/cleanup.py` | cleanup — clears raw-row `meteringPending` (per-key UpdateItem) | `scripts/cleanup.py` |\n| `src/handlers/submitter.py` | submitter — reads aggregated_usage, BatchMeterUsage, writes back | `scripts/submitter.py` |\n| `src/handlers/expiry.py` | expiry — scheduled aggregated_usage age-out (>24h / post-grace previous-month) | `scripts/expiry.py` |\n| `src/handlers/subscription.py` | EventBridge/SNS notification processing | `scripts/subscription.py` |\n| `src/requirements.txt` | Python dependencies (latest AWS SDK) | `assets/requirements.txt` |\n\n> **Important:** Always include `requirements.txt` with `boto3>=1.35.0` in the `src/` directory. SAM will bundle the latest AWS SDK with the Lambda functions. The Lambda runtime's built-in boto3 may be outdated and missing support for newer Marketplace API features (e.g., `LicenseArn` in `BatchMeterUsage`).\n\n**Placeholders to replace:**\n\n- `{{PRODUCT_CODE}}` — the actual metering product code\n- `{{DEPLOYMENT_REGION}}` — e.g. `us-west-2`\n- `{{STACK_PREFIX}}` — a unique prefix for resource names to avoid collisions (e.g. `myapp`)\n\n### Step 2: Deploy Events Stack FIRST (us-east-1)\n\nThe events stack must deploy first because it creates the subscriptions table that the main stack references.\n\n```bash\nsam build --template-file template-events.yaml --region us-east-1\nsam deploy \\\n --template-file .aws-sam/build/template.yaml \\\n --stack-name awsmp-events-stack \\\n --region us-east-1 \\\n --capabilities CAPABILITY_NAMED_IAM \\\n --s3-bucket aws-sam-cli-managed-default-\u003cACCOUNT_ID>-us-east-1 \\\n --no-confirm-changeset \\\n --no-fail-on-empty-changeset\n```\n\n> Use `--capabilities CAPABILITY_NAMED_IAM` (the events stack has a named role, `awsmp-events-subscription-role`) and `--s3-bucket` with a directly-provisioned artifact bucket — **not** `--resolve-s3`, which bootstraps a non-`awsmp-*` `aws-sam-cli-managed-default` CloudFormation stack the least-privilege deployer policy denies. `deploy.sh` creates the SSE+versioned artifact bucket for you (per region) and passes `--s3-bucket`.\n\nNote the `SubscribersTableName` from the stack outputs.\n\n#### Wiring alarm notifications to your ticketing system\n\nAlarms are always created, but their notification actions are OPTIONAL: pass an in-region\nSNS topic ARN via `AlertsTopicArn` (a us-east-1 topic for the events stack; a\nmetering-region topic for the main stack) and the alarms will notify it. Neither the\ndeployer role nor any Lambda publishes to the topic — CloudWatch fires the action, and\nacting on alarms is the seller's responsibility. Create your own SNS topic and subscribe\nyour alerting/ticketing system to it:\n\n> **Encrypt the topic at rest.** Alarm notifications can carry sensitive identifiers (buyer AWS account IDs, license ARNs, resource names), so create the SNS topic with server-side encryption — the AWS-managed key `alias/aws/sns` at minimum, or a customer-managed KMS key: `aws sns create-topic --name \u003cNAME> --attributes KmsMasterKeyId=alias/aws/sns`.\n>\n> **Validate recipients.** Ensure only authorized operations personnel are subscribed. Review SNS subscriptions periodically and consider SNS access / subscription filter policies.\n\n- **Email (simplest):** `aws sns subscribe --topic-arn \u003cTOPIC_ARN> --protocol email --endpoint [email protected]`\n- **Jira Cloud:** Use [AWS Chatbot → Jira integration](https://docs.aws.amazon.com/chatbot/latest/adminguide/jira-setup.html) or Jira's native SNS subscription\n- **PagerDuty:** Create a PagerDuty [SNS integration](https://support.pagerduty.com/docs/aws-cloudwatch-integration-guide) (Events API v2 endpoint as HTTPS subscription)\n- **ServiceNow:** Deploy a thin Lambda subscriber that calls the SNOW REST API (`/api/now/table/incident`)\n- **Slack:** Use [AWS Chatbot → Slack](https://docs.aws.amazon.com/chatbot/latest/adminguide/slack-setup.html)\n\nThen pass the topic ARN as `AlertsTopicArn` when deploying each stack.\n\n### Step 3: Deploy Main Stack (Seller's Region)\n\nUse the product code in the stack name to avoid conflicts (especially for multi-product sellers). `\u003cDEPLOYMENT_REGION>` is the seller's metering region and **may be `us-east-1`** — us-east-1 is a supported `BatchMeterUsage` region. When it is us-east-1, the main stack simply deploys alongside the events stack (single-region, no cross-region reads). For the full list of regions where `BatchMeterUsage` is supported, see [BatchMeterUsage region support](https://docs.aws.amazon.com/marketplace/latest/developerguide/metering-regions.html#batchmeterusage-region-support):\n\n```bash\nsam build --region \u003cDEPLOYMENT_REGION>\nsam deploy \\\n --stack-name awsmp-\u003cPRODUCT_CODE>-metering \\\n --region \u003cDEPLOYMENT_REGION> \\\n --capabilities CAPABILITY_NAMED_IAM \\\n --s3-bucket aws-sam-cli-managed-default-\u003cACCOUNT_ID>-\u003cDEPLOYMENT_REGION> \\\n --no-confirm-changeset \\\n --no-fail-on-empty-changeset \\\n --parameter-overrides \\\n \"ProductCode=\u003cPRODUCT_CODE>\" \\\n \"StackPrefix=awsmp-\u003cPRODUCT_CODE>\" \\\n \"SubscribersTableName=\u003cSUBSCRIBERS_TABLE_NAME>\" \\\n \"StageName=\u003cSTAGE_NAME_FROM_QUESTIONNAIRE>\" \\\n \"WebAclArn=\u003cWAFV2_WEBACL_ARN>\" \\\n \"LogsKmsKeyArn=\u003cKMS_KEY_ARN_FOR_LOGS>\" \\\n \"LogRetentionInDays=90\" \\\n \"AllowedRegistrationFields=\u003ccomma,separated,form,fields>\" \\\n \"PromotedProfileFields=\u003csubset,to,index>\" \\\n \"PermissionsBoundaryName=awsmp-metering-boundary\"\n```\n\n> **Prerequisite — permissions boundary MUST exist first.** SAM auto-generates the Lambda\n> execution roles, and the deployer role only permits `iam:CreateRole` when a matching\n> `iam:PermissionsBoundary` is attached. Create the boundary policy (see\n> `references/iam-credentials.md` for `boundary.json`) BEFORE deploying, and keep its name\n> equal to `PermissionsBoundaryName` (default `awsmp-metering-boundary`). `deploy.sh` checks\n> for it and fails fast if missing:\n>\n> ```bash\n> aws iam create-policy --policy-name awsmp-metering-boundary --policy-document file://boundary.json\n> ```\n>\n> **Security parameters:**\n>\n> - `StageName` has **no default** and an `AllowedPattern` that rejects an empty value; it is a **mandatory** questionnaire answer (Q4) — the seller MUST supply a non-empty value (e.g. `v1`, `live`, or `prod` — their choice). A direct `sam deploy` fails fast at the template level if it is missing/empty, and `deploy.sh` performs the same check; there is no fallback to `prod`.\n> - `WebAclArn` — strongly recommended: associate a WAFv2 REGIONAL WebACL with the unauthenticated registration stage (rate-based + common-exploit rules). Omit only if a WAF is attached out-of-band.\n> - `LogsKmsKeyArn` — KMS-encrypts the Lambda + API access log groups (they contain buyer account IDs / license ARNs). The key policy must allow `logs.\u003cregion>.amazonaws.com`.\n> - `AllowedRegistrationFields` — the ONLY registration-form fields the public register Lambda will persist (each length-bounded), written as a `registrationData` map on the **in-region `customer-profile` table** (buyer PII stays in-region; the us-east-1 subscribers table is PII-free). Leave empty to persist none.\n> - `PromotedProfileFields` — a subset of `AllowedRegistrationFields` to promote to top-level attributes on the customer-profile table so a GSI can look profiles up by that field (e.g. `email`). Add the matching `AttributeDefinition` + GSI in the `CustomerProfileTable` `TODO(seller)` block. Leave empty for none.\n\n### Step 4: Configure Fulfillment URL\n\n1. Determine the Fulfillment URL: the **branded registration page URL** (the CloudFront/custom-domain front-end from `references/registration-page.md`). Use the raw `RegistrationUrl` main-stack output ONLY as a bare-bones smoke-test fallback.\n2. Go to [AWS Marketplace Management Portal](https://aws.amazon.com/marketplace/management/products/saas)\n3. Select the product → Request changes → Update fulfillment options → Edit default fulfillment URL\n4. Paste the branded page URL (the Marketplace redirect must land the buyer's browser on a page that renders; the page then POSTs the token + captured fields to `RegistrationUrl`)\n\n> **The seller does this manually.** The skill/deployer intentionally has NO catalog-write\n> permission (no `aws-marketplace:StartChangeSet`) — updating the product's Fulfillment URL is\n> a seller action in the Management Portal. For testing, the seller sets it to the\n> `RegistrationUrl` output above; do not request catalog-write access to automate this.\n>\n> **`RegistrationUrl` is a backend, not the finished onboarding page.** For a smoke test the\n> Fulfillment URL can point straight at it, but most sellers want a **branded buyer-facing\n> registration page** (S3 + CloudFront, optional custom domain) that captures extra buyer\n> fields — including the Know Your Buyer (KYB) fields — and forwards the token + fields to\n> `RegistrationUrl`. Offer to build it and set `ALLOWED_REGISTRATION_FIELDS` accordingly. See\n> `references/registration-page.md`.\n\n### Step 5: Test End-to-End\n\nThe agent runs the verification steps below directly. The **manual, seller/browser\nprerequisites — allowlist, subscribe, and Set up your account — are NOT restated here to\navoid drift; they are owned authoritatively by `references/test-plan.md` §0** (which also\ncovers the AMMP Fulfillment-URL change set and the seller↔skill handshake). Complete\ntest-plan.md §0 first, then run these.\n\n> **This is the core happy path.** For the FULL structured test plan — the manual prerequisites (test-plan.md §0), the scoped test-case suite (registration, rejects by reason, server-side `InvalidUsageDimension`, idempotency, age-out/expiry, month-boundary, direct-submit cases), seller-supplied custom tests, and how to POLL for asynchronous `License Updated`/EventBridge results (default: up to ~5 min at 15s intervals) — see `references/test-plan.md`.\n\n1. **Verify registration + license** — after test-plan.md §0 (subscribe → Set up your account), poll the unified subscribers table in us-east-1 until `licenseArn` and `productCode` are populated (from the `License Updated` event; default poll up to ~5 min at 15s intervals):\n\n ```bash\n aws dynamodb scan --table-name \u003cSTACK_PREFIX>-subscribers --region us-east-1\n ```\n\n Confirm `customerAWSAccountId`, `productCode`, `licenseArn`, and `subscriptionStatus=active`.\n\n2. **Record test usage** — insert a dummy usage row. The usage table is keyed by `licenseArn` (PK) + `customerAWSAccountId#dimension#timestamp` (SK composite, **SECOND precision**), and the pipeline discovers pending usage via the `metering_pending` GSI. The sort-key suffix MUST be an exact whole-second timestamp `YYYY-MM-DDTHH:MM:SS` (NOT millisecond — the aggregator rejects a non-second suffix as `MalformedTimestamp`), its account/dimension segments MUST match the row's own `customerAWSAccountId`/`dimension` attributes, and `meteringPending` MUST equal the row's hour bucket (`YYYY-MM-DDTHH`) so it appears in the GSI. Only COMPLETED hours are processed (`now-23h … now-1h`) and only after the configured `MeteringLockHours`, so use a recent PAST hour older than the lock (default lock 1 ⇒ any hour ≥1h ago works):\n\n ```bash\n HOUR=$(date -u -d '2 hours ago' +%Y-%m-%dT%H) # completed UTC hour, older than the default lock\n SEC=\"${HOUR}:00:07\" # whole-second UTC precision (NOT .000Z, NOT local time)\n aws dynamodb put-item --table-name \u003cSTACK_PREFIX>-usage --region \u003cDEPLOYMENT_REGION> --item \"{\n \\\"licenseArn\\\": {\\\"S\\\": \\\"\u003cLICENSE_ARN>\\\"},\n \\\"customerAWSAccountId_dimension_timestamp\\\": {\\\"S\\\": \\\"\u003cBUYER_ACCOUNT_ID>#\u003cDIMENSION_KEY>#${SEC}\\\"},\n \\\"customerAWSAccountId\\\": {\\\"S\\\": \\\"\u003cBUYER_ACCOUNT_ID>\\\"},\n \\\"dimension\\\": {\\\"S\\\": \\\"\u003cDIMENSION_KEY>\\\"},\n \\\"timestamp\\\": {\\\"S\\\": \\\"${SEC}\\\"},\n \\\"meteringPending\\\": {\\\"S\\\": \\\"${HOUR}\\\"},\n \\\"quantity\\\": {\\\"N\\\": \\\"5\\\"}\n }\"\n ```\n\n3. **Trigger the pipeline** — the pipeline is decoupled (discoverer → work SQS → aggregator → cleanup SQS → cleanup; a scheduled submitter drains `aggregated_usage`). Invoke the **discoverer** in `meter` mode for the hour offset matching your test hour (offset = hours-ago; here `2`), then let the SQS chain run, then invoke the **submitter**:\n\n ```bash\n # a) discover + enqueue the group for that completed hour (offset must be >= MeteringLockHours)\n aws lambda invoke --function-name \u003cDISCOVERER_FUNCTION_NAME> --region \u003cDEPLOYMENT_REGION> \\\n --payload '{\"mode\":\"meter\",\"hourOffset\":2}' /tmp/discoverer.json && cat /tmp/discoverer.json\n # b) the aggregator (SQS-triggered) folds the group and conditionally writes aggregated_usage;\n # the cleanup Lambda (SQS-triggered) then stamps the raw row Aggregated. Wait a few seconds.\n sleep 10\n # c) submit the aggregated record to BatchMeterUsage\n aws lambda invoke --function-name \u003cSUBMITTER_FUNCTION_NAME> --region \u003cDEPLOYMENT_REGION> \\\n --payload '{}' /tmp/submitter.json && cat /tmp/submitter.json\n ```\n\n4. **Verify each stage succeeded:**\n\n ```bash\n # Raw row finalized as Aggregated (meteringPending removed, meteringStatus=Aggregated):\n aws dynamodb get-item --table-name \u003cSTACK_PREFIX>-usage --region \u003cDEPLOYMENT_REGION> \\\n --key \"{\\\"licenseArn\\\":{\\\"S\\\":\\\"\u003cLICENSE_ARN>\\\"},\\\"customerAWSAccountId_dimension_timestamp\\\":{\\\"S\\\":\\\"\u003cBUYER_ACCOUNT_ID>#\u003cDIMENSION_KEY>#${SEC}\\\"}}\"\n # Aggregated record carries a MeteringRecordId + meteringStatus=Success after the submitter:\n aws dynamodb scan --table-name \u003cSTACK_PREFIX>-aggregated-usage --region \u003cDEPLOYMENT_REGION>\n # Submitter log shows a successful BatchMeterUsage (no InvalidUsageDimension / CustomerNotSubscribed):\n aws logs filter-log-events --log-group-name /aws/lambda/\u003cSUBMITTER_FUNCTION_NAME> \\\n --region \u003cDEPLOYMENT_REGION> --start-time $(date -d '5 minutes ago' +%s000) \\\n --query 'events[*].message' --output text\n ```\n\n - A raw row still carrying `meteringPending` after the run, plus a `UsageRecordRejected` metric, means the **writer contract was violated** — check the reason code (`MalformedTimestamp`, `MalformedSortKey`, `SortKeyMismatch`, `MissingDimension`, `NegativeQuantity`, `NonIntegerQuantity`, …) in the aggregator log and fix the row.\n - An `aggregated_usage` row with `meteringStatus=RejectedClientSide` and reason `InvalidUsageDimensionException` means the dimension key is not defined in the catalog — `BatchMeterUsage` failed the call with a request-level `InvalidUsageDimensionException` (server-authoritative), which the submitter isolated to this record (surfaced on the `BatchMeterUsageException` metric). Fix the dimension in the AMMP listing (no redeploy needed).\n\n### Step 6: Validate via Logs\n\n```bash\n# Registration Lambda logs\naws logs filter-log-events \\\n --log-group-name /aws/lambda/\u003cREGISTER_FUNCTION_NAME> \\\n --region \u003cDEPLOYMENT_REGION> \\\n --start-time $(date -d '1 hour ago' +%s000) \\\n --query 'events[*].message' --output text\n\n# Pipeline logs (decoupled: discoverer / aggregator / cleanup / submitter)\nfor fn in \u003cDISCOVERER_FUNCTION_NAME> \u003cAGGREGATOR_FUNCTION_NAME> \u003cCLEANUP_FUNCTION_NAME> \u003cSUBMITTER_FUNCTION_NAME>; do\n echo \"=== $fn ===\"\n aws logs filter-log-events \\\n --log-group-name \"/aws/lambda/$fn\" \\\n --region \u003cDEPLOYMENT_REGION> \\\n --start-time $(date -d '1 hour ago' +%s000) \\\n --query 'events[*].message' --output text\ndone\n\n# Subscription Lambda logs (always us-east-1)\naws logs filter-log-events \\\n --log-group-name /aws/lambda/\u003cSUBSCRIPTION_FUNCTION_NAME> \\\n --region us-east-1 \\\n --start-time $(date -d '1 hour ago' +%s000) \\\n --query 'events[*].message' --output text\n```\n\n## Per-region prerequisite: API Gateway CloudWatch Logs account role\n\nBecause the registration API enables stage access logging, API Gateway requires a\n**per-region, account-level** CloudWatch Logs role BEFORE any logging-enabled stage can be\ncreated — otherwise stage create fails with \"CloudWatch Logs role ARN must be set in\naccount settings to enable logging\". This is an account-global setting **per region**, so\nthe first region you deploy to may already have it while every additional region needs it\nset. `deploy.sh` provisions this idempotently for each metering region (`ensure_apigw_cw_role`):\nit creates the `awsmp-apigw-cloudwatch-logs` role once and sets the region's\n`cloudwatchRoleArn` if unset. If deploying by hand, run before the main stack per region:\n\n```bash\naws apigateway update-account --region \u003cDEPLOYMENT_REGION> \\\n --patch-operations op=replace,path=/cloudwatchRoleArn,value=arn:aws:iam::\u003cACCOUNT_ID>:role/awsmp-apigw-cloudwatch-logs\n```\n\n## Recovering from a failed deploy\n\nA mid-deploy failure/rollback can leave behind `DeletionPolicy: Retain` resources — the\nusage table, the subscribers table, the subscription role, and especially the\n`/aws/lambda/awsmp-*` **log groups** (which are easy to miss — they are not listed in the\nrollback resource summary) — and can orphan a partially-created baseline WebACL. On retry\nthese collide (`AWS::EarlyValidation::ResourceExistenceCheck`, or WebACL `AlreadyExists`).\n\n`deploy.sh` runs a **read-only** preflight that detects likely collisions and prints\nguidance; it never auto-deletes. Remove a reviewed leftover resource with your own\ncredentials, then retry:\n\n- Leftover log group after a failed first deploy:\n `aws logs delete-log-group --log-group-name /aws/lambda/\u003cprefix>-\u003cfn> --region \u003cregion>`\n- Empty usage table after a **failed first** deploy (only when it holds no billable usage —\n never delete a table with real usage):\n `aws dynamodb delete-table --table-name \u003cprefix>-usage --region \u003cregion>`\n- Orphaned baseline WebACL (`AlreadyExists`): the real fix is prevention — the deployer\n policy grants `wafv2:ListTagsForResource` + `wafv2:GetWebACLForResource` so the create+tag\n succeeds atomically. If an orphan does exist, delete the reviewed WebACL with your own\n credentials, then retry. The deployer role deliberately does NOT have account-wide\n `wafv2:ListWebACLs`/delete to auto-clean.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":19969,"content_sha256":"9dacd47e0b7e969dfc9ee9ccdd573867d54034c7d225f853b04ce97d9aa5acff"},{"filename":"references/dimension-reference.md","content":"# Metering Dimension Reference\n\n## Known Dimensions\n\nThese dimensions represent the usage metrics reported via AWS Marketplace BatchMeterUsage API:\n\n| Dimension | Description |\n|-----------|-------------|\n| `fgt_cnf_hours` | FortiGate CNF instance running hours. Measures the number of hours the CNF firewall instances are active. May include UsageAllocations with SecurityVPC tags. |\n| `fgt_cnf_ips_proc` | FortiGate CNF IPS (Intrusion Prevention System) data processed. Measures the volume of traffic inspected by the IPS engine. |\n| `fgt_cnf_sec_proc` | FortiGate CNF security processing. Measures general security processing workload. |\n| `fgt_cnf_eco_instance_hrs` | FortiGate CNF economy instance hours. Measures running hours for economy-tier firewall instances. May include UsageAllocations with SecurityVPC tags. |\n| `Site_Number` | Number of monitored sites. |\n| `Transfer_Data` | Data transfer volume. |\n| `Vulnerability_Scan` | Number of vulnerability scans performed. |\n\n## UsageAllocations\n\nSome records include `UsageAllocations` with tags that break down usage by SecurityVPC. This is common for:\n\n- `fgt_cnf_hours` — allocated per SecurityVPC\n- `fgt_cnf_eco_instance_hrs` — allocated per SecurityVPC\n- `fgt_cnf_ips_proc` — allocated per SecurityVPC (for centralized firewall policies)\n\n## Data Period\n\nDo not assume a fixed billing cycle or date range. Determine the current dimensions and active billing period dynamically:\n\n- Query the product's configured dimensions via the Catalog API: `aws marketplace-catalog describe-entity --catalog AWSMarketplace --entity-id \u003cPRODUCT_ID> --region us-east-1`\n- Metered usage bills on the 2nd–3rd of each month for the prior month; use the seller's requested window for queries rather than a hardcoded range.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1776,"content_sha256":"38b43b00d822998fe7a9c77201e648803e9bde29fe1e8cb7465a71ab4f95285c"},{"filename":"references/existing-sellers.md","content":"## Multi-Product Architecture\n\n> **Key Fact — Shared Infrastructure:** Multiple products MUST share a SINGLE events stack (`awsmp-events-stack`). Deploy ONE shared events stack + ONE main stack PER product (`awsmp-\u003cproductCode>-metering`). NEVER deploy separate events stacks per product.\n>\n> **Key Fact — Deduplication Scope:** For CA products, dedup is per (CustomerAWSAccountId + LicenseArn + dimension + hour). For legacy products, dedup is per (ProductCode + CustomerIdentifier + dimension + hour). Products sharing one Lambda will NOT interfere with each other. Each `BatchMeterUsage` call targets a single ProductCode.\n>\n> **Key Fact — Per-Product Routing:** When sharing a Lambda across products, route by ProductCode. Each product has its own dimensions — never mix dimensions across products in a single API call.\n\n## Handling Existing Sellers (already deployed)\n\n> ⚠️ **CRITICAL — CHECK BEFORE DEPLOY:** Before deploying ANY stack, you MUST first check for existing infrastructure. If an events stack already exists, DO NOT redeploy it — reuse its outputs:\n>\n> ```bash\n> aws cloudformation list-stacks --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE --query \"StackSummaries[?contains(StackName, 'awsmp-') || contains(StackName, 'aws-marketplace-events')]\" --region us-east-1\n> ```\n\n### When direct deployment is allowed (visibility + existing-stack gate)\n\nDirect deployment/stack mutation from this skill is allowed ONLY when BOTH hold:\n\n1. **The CURRENT product is in `Limited` visibility.** Never deploy or mutate a stack for a `Published` product — for a Published product, provide the code for the seller's own pipeline/change-management instead. Because the **main stack is product-specific** (`awsmp-\u003cproductCode>-metering`), this check is on the CURRENT product's visibility ONLY — a different product's visibility is irrelevant to this product's main stack. Verify via the Catalog API before deploying.\n2. **There is no existing stack for this scope** — or, if there is, deploy ONLY with the seller's EXPLICIT confirmation, and ONLY for the CURRENT product's main stack. Do NOT silently redeploy/mutate an existing stack.\n\n⚠️ **The events stack is SHARED across all of a seller's products.** It may already be serving OTHER products — including `Published` ones with live production traffic. Mutating or redeploying it can break metering for those products. Therefore:\n\n- Treat the events stack as **do-not-touch if it already exists**: reuse its outputs (below), never redeploy it, even when the current product is `Limited`. A `Limited` current product does NOT authorize changing shared infrastructure that other (possibly `Published`) products depend on.\n- Only the FIRST product ever onboarded creates the events stack (when none exists). Any subsequent onboarding reuses it.\n- Do not gate the events stack on the current product's visibility — its safety depends on whether OTHER products are already served by it, which a per-product visibility check cannot see; the safe default is to never mutate an existing shared events stack without explicit, informed seller confirmation of the blast radius.\n\nThe **main stack** deployment gate checks only the CURRENT product's visibility (`Limited`) because the main stack is dedicated to that product; a fresh main stack for a `Limited` product with no existing per-product stack is the normal direct-deploy path.\n\n### Existing events stack detected\nIf `awsmp-events-stack` (or any legacy differently-named events stack a seller deployed earlier, e.g. one matching `aws-marketplace-events`) exists in us-east-1:\n\n- **Do NOT redeploy it** — reuse it (it is SHARED and may serve other, possibly `Published`, products; redeploying risks their production traffic)\n- Read its outputs: `aws cloudformation describe-stacks --stack-name \u003cname> --query \"Stacks[0].Outputs\"`\n- Use the `SubscribersTableName` output for the main stack\n\n### Existing main stack detected\nIf `awsmp-\u003cproductCode>-metering` stack exists (this is the CURRENT product's own stack):\n\n- **Do NOT redeploy the full stack** — modify/extend only, and only with the seller's confirmation\n- **To add or rename a pricing dimension:** update the AWS Marketplace listing (AMMP) — that's it. The metering pipeline is **not** configured with a dimension list, so there is **nothing to change in the stack or the Lambdas** and **no redeploy** for a dimension change. Once the seller's writer starts emitting usage rows for the new dimension, the pipeline aggregates and submits them, and `BatchMeterUsage` accepts them as soon as the catalog defines the dimension (until then it fails with a request-level `InvalidUsageDimensionException`, which is isolated + alarmed).\n > This is a change from earlier versions, which baked a `DIMENSIONS` env var into the Meter Lambda and required an env-var update per dimension change. That coupling has been removed — dimension validity is server-authoritative.\n\n### Partial stack (events deployed, main missing)\n\n- ⚠️ The events stack ALREADY EXISTS — DO NOT deploy it again\n- Run: `aws cloudformation describe-stacks --stack-name awsmp-events-stack --region us-east-1 --query \"Stacks[0].Outputs\"` to get SubscribersTableName\n- Deploy ONLY the main stack using the existing SubscribersTableName\n- If you redeploy events, you will create a DUPLICATE and break the integration\n\n### Adding a new capability to an EXISTING stack (adapt — do NOT impose the packaged shape)\n\nWhen a seller already has a working stack, the capabilities this skill adds are **layered onto their ACTUAL resources**, never assumed to already match the packaged files. First understand the seller's real subscribers table, register Lambda/API, and metering path (see \"Handling Existing Sellers\" above), then:\n\n- **Buyer PII / registration data (in-region `customer-profile` table).** The seller's existing register handler may ALREADY store registration data — in its own table, a column, or on the subscriber row. Do NOT assume the packaged `register.py` / `CUSTOMER_PROFILE_TABLE` shape is present, and do NOT drop the seller's existing handler in verbatim (the reference `register.py` fails loud if `CUSTOMER_PROFILE_TABLE` is unset — that guard is for a NEW skill-generated stack only).\n - If the seller's subscribers table **already holds buyer PII** (e.g. a `registrationData` attribute) and they want it moved in-region, this is a **data migration + code cutover**, not a drop-in: provision the new per-Region profile table, dual-write or backfill existing rows, then stop writing PII to the subscribers row. Make each step explicit; never silently drop the existing PII attribute or assume the table is already PII-free.\n - If the seller is happy with their current arrangement, respect it and only explain the data-residency tradeoff (keeping buyer PII out of the shared us-east-1 table).\n\n- **Expedited deprovisioning flush (sparse index + flush rule + deprovision-work queue + cleanup Lambda).** These are additive — the sparse GSI is an online index (no data migration). But add them against the seller's ACTUAL subscribers-table name and their existing discoverer/submitter function names/roles (merge IAM + env vars, do not overwrite). If the seller's pipeline already finalizes deprovisioning some other way, reconcile with it rather than blindly layering the cleanup Lambda on top.\n\nIn all cases confirm the change set with the seller first, and apply the edits to the seller's OWN materialized files (code/stack parity) rather than the packaged assets.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":7564,"content_sha256":"e942069653d1ca3fee604e2facf078b2b1b2cc5be5c1e427c7b27d0b69b574bd"},{"filename":"references/iam-credentials.md","content":"# Credentials & IAM — Prerequisite setup (do this FIRST, one time, before running the skill)\n\n> **FIRST-TIME SELLERS START HERE.** Before the skill can deploy anything, TWO IAM artifacts\n> must exist in the seller's AWS account — this is a **one-time prerequisite** the skill does\n> NOT create for you (bootstrapping IAM for yourself would be a privilege-escalation path), and\n> deployment **fails closed** without them. Walk the seller through these in order; it takes a\n> few minutes and is done once per account.\n>\n> 1. **`awsmp-metering-boundary`** — a *permissions boundary* (a managed policy) that CAPS what\n> any `awsmp-*` role can ever do. **Must exist BEFORE the deployer role**, because the\n> deployer's `iam:CreateRole` is gated on it — a missing/misnamed boundary ⇒ `AccessDenied`.\n> 2. **`MarketplaceMeteringDeployer`** — the *least-privilege deployer role* the skill ASSUMES\n> to create the `awsmp-*` stacks. It has a **conditioned** trust policy (never open `:root`).\n>\n> **Who runs this:** an administrator / IAM-capable principal in the SELLER's account runs the\n> four commands below ONCE (they need `iam:CreatePolicy`/`iam:CreateRole`/`iam:PutRolePolicy`).\n> This is distinct from the ephemeral credentials used to run the skill (next section): after\n> the role exists, the skill is run by ASSUMING `MarketplaceMeteringDeployer` (or via SSO into an\n> equivalent). The seller does NOT hand the skill long-lived keys.\n\n## Step-by-step (copy-paste; replace the `\u003c...>` placeholders)\n\n```bash\n# ── 0. Choose names/values (the boundary name is referenced in THREE places — keep them equal) ──\nBOUNDARY_NAME=awsmp-metering-boundary # must equal the deployer condition AND the\n # PermissionsBoundaryName template parameter\nROLE_NAME=MarketplaceMeteringDeployer\nEXTERNAL_ID=\u003cCHOOSE-A-SECRET-EXTERNAL-ID> # shared secret the caller presents on AssumeRole\nCALLER_PRINCIPAL=\u003carn-of-the-principal-that-will-assume-the-role> # e.g. your SSO/role ARN\n\n# ── 1. Write the two policy documents to disk ──\n# boundary.json = the permissions-boundary policy (full JSON is in\n# \"A boundary that caps the generated Lambda roles ...\" below — copy it)\n# policy.json = the deployer permissions policy (full JSON is in\n# \"Full IAM policy for MarketplaceMeteringDeployer role\" below — copy it)\n\n# ── 2. Create the permissions boundary FIRST (a boundary MUST be a managed policy) ──\naws iam create-policy --policy-name \"$BOUNDARY_NAME\" --policy-document file://boundary.json\n\n# ── 3. Create the deployer role with a CONDITIONED trust policy (NOT open :root) ──\n# DEFAULT below = headless-agent caller: role-ARN principal + sts:ExternalId, NO MFA.\naws iam create-role --role-name \"$ROLE_NAME\" --assume-role-policy-document \"{\n \\\"Version\\\": \\\"2012-10-17\\\",\n \\\"Statement\\\": [{\n \\\"Effect\\\": \\\"Allow\\\",\n \\\"Principal\\\": {\\\"AWS\\\": \\\"$CALLER_PRINCIPAL\\\"},\n \\\"Action\\\": \\\"sts:AssumeRole\\\",\n \\\"Condition\\\": {\\\"StringEquals\\\": {\\\"sts:ExternalId\\\": \\\"$EXTERNAL_ID\\\"}}\n }]\n}\"\n# (Human operator instead of a headless agent? You MAY additionally require MFA by adding\n# \"Bool\": {\"aws:MultiFactorAuthPresent\": \"true\"} to the Condition — human-only variant.)\n\n# ── 4. Attach the deployer permissions policy to the role ──\naws iam put-role-policy --role-name \"$ROLE_NAME\" --policy-name DeployPolicy \\\n --policy-document file://policy.json\n```\n\n> **Why the ORDER matters:** the deployer policy's `iam:CreateRole` is conditioned on\n> `iam:PermissionsBoundary = arn:aws:iam::*:policy/awsmp-metering-boundary`. If the boundary does\n> not exist (or is named differently) when the first deploy runs, SAM's attempt to create the\n> Lambda execution roles is **denied** and the stack rolls back. Create the boundary (step 2)\n> BEFORE the role (step 3), keep the boundary NAME identical across the deployer condition, this\n> `create-policy`, and the `PermissionsBoundaryName` template parameter (default\n> `awsmp-metering-boundary`), and `deploy.sh` will pass its pre-flight check.\n>\n> **Verify the prerequisite is in place before running the skill:**\n>\n> ```bash\n> aws iam get-policy --policy-arn arn:aws:iam::\u003cACCOUNT_ID>:policy/awsmp-metering-boundary # boundary exists\n> aws iam get-role --role-name MarketplaceMeteringDeployer # deployer role exists\n> ```\n>\n> Both must return successfully. THEN configure the ephemeral credentials below (assume the\n> deployer role, or SSO) and run `aws sts get-caller-identity` to confirm the correct seller\n> account before the skill does anything.\n\nThe full policy JSONs referenced above (`boundary.json` and `policy.json`) are later in this\ndocument: the deployer `policy.json` under **\"Full IAM policy for MarketplaceMeteringDeployer\nrole\"**, and `boundary.json` under **\"A boundary that caps the generated Lambda roles ...\"**.\n\n---\n\n> **Credentials MUST be ephemeral-first.** Prefer, in order: (1) IAM Identity\n> Center / SSO, then (2) `aws sts assume-role` session credentials (which include\n> `AWS_SESSION_TOKEN`). Do NOT ask the seller to provide, paste, or export long-lived IAM\n> **user** access keys, and do NOT ask for credentials outright as a first step — have the\n> seller configure one of the ephemeral paths in their own environment. **Never log or echo\n> credential values.**\n\n### Option A: IAM Identity Center (SSO) — recommended, default\n\n```bash\naws configure sso\naws sso login\n# or a named SSO profile\nexport AWS_PROFILE=marketplace-seller\n```\n\n### Option B: STS assume-role (ephemeral session credentials)\n\nCreate a dedicated deployer role (see policy below) and assume it — the session includes\n`AWS_SESSION_TOKEN` and expires automatically:\n\n```bash\naws sts assume-role \\\n --role-arn arn:aws:iam::\u003cACCOUNT_ID>:role/MarketplaceMeteringDeployer \\\n --role-session-name mp-metering-setup\n# export the returned AccessKeyId / SecretAccessKey / SessionToken\n```\n\n### Option C (last resort only): short-lived environment-variable credentials\n\n```bash\n# ⚠️ LAST RESORT — prefer SSO (A) or sts assume-role (B) above.\n# Use ONLY short-lived STS session credentials (with AWS_SESSION_TOKEN), never\n# long-lived IAM user access keys. If credentials must be stored, use AWS Secrets\n# Manager or SSM Parameter Store — never plaintext files or source control.\nexport AWS_ACCESS_KEY_ID=\u003ctemporary-key>\nexport AWS_SECRET_ACCESS_KEY=\u003ctemporary-secret>\nexport AWS_SESSION_TOKEN=\u003csession-token> # REQUIRED — do not use keys without a session token\n```\n\n**Verify credentials immediately after configuration:**\n\n```bash\naws sts get-caller-identity\n```\n\nConfirm the output shows the correct seller account ID. **Do not proceed until this succeeds.** These same credentials will be used for all subsequent operations: Catalog API validation, stack deployment, and integration testing.\n\n### Required IAM Permissions\n\nIf the seller asks \"what permissions do I need?\", provide the policy below. Otherwise, proceed with whatever credentials they provide — if a permission is missing, the error will indicate exactly which action is needed.\n\n\u003cdetails>\n\u003csummary>Click to expand: Full IAM policy for MarketplaceMeteringDeployer role\u003c/summary>\n\n> **Tag-authorization principle (why the policy is split into Create / Manage / Read buckets):** Tags supplied at creation are authorized with `aws:RequestTag`, **never** `aws:ResourceTag` — on a create the resource does not exist yet, so it has no tags and an `aws:ResourceTag/*` condition is always false, denying the create. Services that attach tags/targets/sub-resources in a **separate call after** the parent resource is created — **CloudFormation, API Gateway, EventBridge** — cannot be `aws:ResourceTag`-gated for their create/rollback lifecycle at all (gating them also wedges rollback in `DELETE_FAILED`/`ROLLBACK_FAILED`). Only services CloudFormation tags **atomically at creation** (Lambda, DynamoDB, IAM roles) can keep `aws:ResourceTag/ManagedBy` on their non-create operations. Read actions (`Get*`/`Describe*`/`List*`/`Query`/`Scan`/`apigateway:GET`) are also **never** tag-gated, because CloudFormation issues describe/GET waiters during creation (e.g. `dynamodb:DescribeTable` waiting for `ACTIVE`, `lambda:GetFunction`) against a just-created, not-yet-tagged resource.\n>\n> Buckets: **Create** — no condition, name-scoped `awsmp-*`. **Manage** — `aws:ResourceTag/ManagedBy` for mutations of existing resources. **Read** — no condition, name-scoped. **Special** — `iam:PassRole` scoped by `iam:PassedToService`; unconditioned `aws-marketplace` catalog read.\n>\n> **This policy supports BOTH the SAM change-set deploy flow and non-SAM direct-`UpdateStack` tooling.** `sam deploy` (the packaged `deploy.sh`) drives CloudFormation through `CreateChangeSet`/`ExecuteChangeSet`. The policy ALSO grants `cloudformation:UpdateStack` **un-conditioned** on `stack/awsmp-*` (in the **Create** bucket alongside `CreateStack`/`DeleteStack`), so a seller who instead wraps these templates in non-SAM CloudFormation tooling that calls `UpdateStack` directly — e.g. Terraform's `aws_cloudformation_stack` — works OUT OF THE BOX: both the initial create and subsequent stack UPDATEs succeed. `UpdateStack` is NOT tag-gated (a non-SAM tool's stack would not carry the skill's `ManagedBy` tag, which would otherwise deny the update), but it stays strictly scoped to `stack/awsmp-*` — it does not broaden the resource scope. **Stack deletion:** `cloudformation:DeleteStack` is likewise name-scoped to `stack/awsmp-*` (for SAM rolled-back cleanup); routine teardown is still a deliberate action you perform after reviewing the `DeletionPolicy: Retain` resources (usage/subscriber tables, log groups) that intentionally survive a stack delete.\n\n```json\n{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Sid\": \"CloudFormationCreate\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"cloudformation:CreateStack\",\n \"cloudformation:UpdateStack\",\n \"cloudformation:DeleteStack\",\n \"cloudformation:CreateChangeSet\",\n \"cloudformation:DescribeChangeSet\",\n \"cloudformation:ExecuteChangeSet\",\n \"cloudformation:DeleteChangeSet\",\n \"cloudformation:ListChangeSets\",\n \"cloudformation:DescribeStacks\",\n \"cloudformation:DescribeStackEvents\",\n \"cloudformation:GetTemplate\",\n \"cloudformation:GetTemplateSummary\",\n \"cloudformation:ListStackResources\"\n ],\n \"Resource\": \"arn:aws:cloudformation:*:*:stack/awsmp-*/*\"\n },\n {\n \"Sid\": \"CloudFormationServerlessTransform\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"cloudformation:CreateChangeSet\"\n ],\n \"Resource\": \"arn:aws:cloudformation:*:aws:transform/Serverless-2016-10-31\"\n },\n {\n \"Sid\": \"S3DeploymentArtifacts\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"s3:CreateBucket\",\n \"s3:PutObject\",\n \"s3:GetObject\",\n \"s3:ListBucket\",\n \"s3:PutBucketPolicy\",\n \"s3:PutBucketVersioning\",\n \"s3:GetBucketLocation\",\n \"s3:PutEncryptionConfiguration\",\n \"s3:GetEncryptionConfiguration\",\n \"s3:PutBucketTagging\"\n ],\n \"Resource\": [\n \"arn:aws:s3:::aws-sam-cli-managed-default-*\",\n \"arn:aws:s3:::aws-sam-cli-managed-default-*/*\"\n ]\n },\n {\n \"Sid\": \"IAMRolesCreate\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"iam:CreateRole\"\n ],\n \"Resource\": \"arn:aws:iam::*:role/awsmp-*\",\n \"Condition\": {\n \"StringLike\": {\n \"iam:PermissionsBoundary\": \"arn:aws:iam::*:policy/awsmp-metering-boundary\"\n }\n }\n },\n {\n \"Sid\": \"IAMRolesManage\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"iam:DeleteRole\",\n \"iam:PutRolePolicy\",\n \"iam:DeleteRolePolicy\",\n \"iam:AttachRolePolicy\",\n \"iam:DetachRolePolicy\",\n \"iam:TagRole\",\n \"iam:UntagRole\"\n ],\n \"Resource\": \"arn:aws:iam::*:role/awsmp-*\",\n \"Condition\": {\n \"StringEquals\": {\n \"aws:ResourceTag/ManagedBy\": \"marketplace-metering-skill\"\n }\n }\n },\n {\n \"Sid\": \"IAMPassRoleScoped\",\n \"Effect\": \"Allow\",\n \"Action\": \"iam:PassRole\",\n \"Resource\": \"arn:aws:iam::*:role/awsmp-*\",\n \"Condition\": {\n \"StringEquals\": {\n \"iam:PassedToService\": [\n \"lambda.amazonaws.com\",\n \"events.amazonaws.com\"\n ]\n }\n }\n },\n {\n \"Sid\": \"LambdaCreate\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"lambda:CreateFunction\",\n \"lambda:PutFunctionConcurrency\"\n ],\n \"Resource\": \"arn:aws:lambda:*:*:function:awsmp-*\"\n },\n {\n \"Sid\": \"LambdaManage\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"lambda:UpdateFunctionCode\",\n \"lambda:UpdateFunctionConfiguration\",\n \"lambda:DeleteFunction\",\n \"lambda:AddPermission\",\n \"lambda:RemovePermission\",\n \"lambda:InvokeFunction\",\n \"lambda:PutFunctionConcurrency\",\n \"lambda:DeleteFunctionConcurrency\",\n \"lambda:TagResource\",\n \"lambda:UntagResource\"\n ],\n \"Resource\": \"arn:aws:lambda:*:*:function:awsmp-*\",\n \"Condition\": {\n \"StringEquals\": {\n \"aws:ResourceTag/ManagedBy\": \"marketplace-metering-skill\"\n }\n }\n },\n {\n \"Sid\": \"DynamoDBCreate\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"dynamodb:CreateTable\"\n ],\n \"Resource\": \"arn:aws:dynamodb:*:*:table/awsmp-*\"\n },\n {\n \"Sid\": \"DynamoDBManage\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"dynamodb:DeleteTable\",\n \"dynamodb:UpdateTable\",\n \"dynamodb:UpdateContinuousBackups\",\n \"dynamodb:UpdateTimeToLive\",\n \"dynamodb:TagResource\",\n \"dynamodb:UntagResource\",\n \"dynamodb:PutItem\"\n ],\n \"Resource\": \"arn:aws:dynamodb:*:*:table/awsmp-*\",\n \"Condition\": {\n \"StringEquals\": {\n \"aws:ResourceTag/ManagedBy\": \"marketplace-metering-skill\"\n }\n }\n },\n {\n \"Sid\": \"SQSCreate\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"sqs:CreateQueue\"\n ],\n \"Resource\": \"arn:aws:sqs:*:*:awsmp-*\"\n },\n {\n \"Sid\": \"SQSManage\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"sqs:DeleteQueue\",\n \"sqs:SetQueueAttributes\",\n \"sqs:SendMessage\",\n \"sqs:TagQueue\",\n \"sqs:UntagQueue\"\n ],\n \"Resource\": \"arn:aws:sqs:*:*:awsmp-*\",\n \"Condition\": {\n \"StringEquals\": {\n \"aws:ResourceTag/ManagedBy\": \"marketplace-metering-skill\"\n }\n }\n },\n {\n \"Sid\": \"SNSCreate\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"sns:CreateTopic\"\n ],\n \"Resource\": \"arn:aws:sns:*:*:awsmp-*\"\n },\n {\n \"Sid\": \"SNSManage\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"sns:DeleteTopic\",\n \"sns:Subscribe\",\n \"sns:TagResource\",\n \"sns:UntagResource\"\n ],\n \"Resource\": \"arn:aws:sns:*:*:awsmp-*\",\n \"Condition\": {\n \"StringEquals\": {\n \"aws:ResourceTag/ManagedBy\": \"marketplace-metering-skill\"\n }\n }\n },\n {\n \"Sid\": \"ApiGatewayCreate\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"apigateway:POST\",\n \"apigateway:PUT\",\n \"apigateway:PATCH\",\n \"apigateway:DELETE\",\n \"apigateway:SetWebACL\"\n ],\n \"Resource\": [\n \"arn:aws:apigateway:*::/restapis*\",\n \"arn:aws:apigateway:*::/tags/*\"\n ]\n },\n {\n \"Sid\": \"WafV2BaselineWebAcl\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"wafv2:CreateWebACL\",\n \"wafv2:GetWebACL\",\n \"wafv2:UpdateWebACL\",\n \"wafv2:AssociateWebACL\",\n \"wafv2:DisassociateWebACL\",\n \"wafv2:DeleteWebACL\",\n \"wafv2:TagResource\",\n \"wafv2:ListTagsForResource\"\n ],\n \"Resource\": [\n \"arn:aws:wafv2:*:*:regional/webacl/awsmp-*/*\",\n \"arn:aws:wafv2:*:*:regional/managedruleset/*/*\"\n ]\n },\n {\n \"Sid\": \"WafV2GetWebAclForResource\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"wafv2:GetWebACLForResource\"\n ],\n \"Resource\": \"arn:aws:wafv2:*:*:regional/webacl/*\"\n },\n {\n \"Sid\": \"ApiGatewayAccountCloudWatchRole\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"apigateway:GET\",\n \"apigateway:PATCH\"\n ],\n \"Resource\": \"arn:aws:apigateway:*::/account\"\n },\n {\n \"Sid\": \"CreateApiGatewayCloudWatchLogsRole\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"iam:CreateRole\",\n \"iam:GetRole\",\n \"iam:DetachRolePolicy\",\n \"iam:DeleteRole\",\n \"iam:TagRole\"\n ],\n \"Resource\": \"arn:aws:iam::*:role/awsmp-apigw-cloudwatch-logs\"\n },\n {\n \"Sid\": \"AttachApiGatewayCloudWatchLogsPolicy\",\n \"Effect\": \"Allow\",\n \"Action\": \"iam:AttachRolePolicy\",\n \"Resource\": \"arn:aws:iam::*:role/awsmp-apigw-cloudwatch-logs\",\n \"Condition\": {\n \"ArnEquals\": {\n \"iam:PolicyARN\": \"arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs\"\n }\n }\n },\n {\n \"Sid\": \"PassApiGatewayCloudWatchLogsRole\",\n \"Effect\": \"Allow\",\n \"Action\": \"iam:PassRole\",\n \"Resource\": \"arn:aws:iam::*:role/awsmp-apigw-cloudwatch-logs\",\n \"Condition\": {\n \"StringEquals\": {\n \"iam:PassedToService\": \"apigateway.amazonaws.com\"\n }\n }\n },\n {\n \"Sid\": \"CloudWatchLogs\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:DeleteLogGroup\",\n \"logs:DescribeLogGroups\",\n \"logs:PutRetentionPolicy\",\n \"logs:TagLogGroup\",\n \"logs:TagResource\",\n \"logs:FilterLogEvents\",\n \"logs:GetLogEvents\",\n \"logs:DescribeLogStreams\"\n ],\n \"Resource\": [\n \"arn:aws:logs:*:*:log-group:/aws/lambda/awsmp-*\",\n \"arn:aws:logs:*:*:log-group:/aws/lambda/awsmp-*:*\",\n \"arn:aws:logs:*:*:log-group:/aws/apigateway/awsmp-*\",\n \"arn:aws:logs:*:*:log-group:/aws/apigateway/awsmp-*:*\"\n ]\n },\n {\n \"Sid\": \"LogsDescribeGlobal\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"logs:DescribeLogGroups\"\n ],\n \"Resource\": \"*\"\n },\n {\n \"Sid\": \"ObservabilityReadOnly\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"cloudtrail:LookupEvents\",\n \"cloudwatch:GetMetricStatistics\",\n \"cloudwatch:GetMetricData\",\n \"cloudwatch:ListDashboards\",\n \"events:ListRuleNamesByTarget\"\n ],\n \"Resource\": \"*\"\n },\n {\n \"Sid\": \"CloudWatchAlarms\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"cloudwatch:PutMetricAlarm\",\n \"cloudwatch:DescribeAlarms\",\n \"cloudwatch:DeleteAlarms\",\n \"cloudwatch:TagResource\"\n ],\n \"Resource\": \"arn:aws:cloudwatch:*:*:alarm:awsmp-*\"\n },\n {\n \"Sid\": \"CloudWatchDashboards\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"cloudwatch:PutDashboard\",\n \"cloudwatch:GetDashboard\",\n \"cloudwatch:DeleteDashboards\"\n ],\n \"Resource\": \"arn:aws:cloudwatch::*:dashboard/awsmp-*\"\n },\n {\n \"Sid\": \"SNSReadOnly\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"sns:GetTopicAttributes\",\n \"sns:ListTagsForResource\"\n ],\n \"Resource\": \"arn:aws:sns:*:*:awsmp-*\"\n },\n {\n \"Sid\": \"LambdaEventSourceMappingReadOnly\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"lambda:GetEventSourceMapping\",\n \"lambda:ListEventSourceMappings\"\n ],\n \"Resource\": \"*\"\n },\n {\n \"Sid\": \"LambdaEventSourceMapping\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"lambda:CreateEventSourceMapping\",\n \"lambda:UpdateEventSourceMapping\",\n \"lambda:DeleteEventSourceMapping\"\n ],\n \"Resource\": \"*\",\n \"Condition\": {\n \"ArnLike\": {\n \"lambda:FunctionArn\": \"arn:aws:lambda:*:*:function:awsmp-*\"\n }\n }\n },\n {\n \"Sid\": \"MarketplaceCatalogReadOnly\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"aws-marketplace:DescribeEntity\",\n \"aws-marketplace:ListEntities\"\n ],\n \"Resource\": \"*\"\n },\n {\n \"Sid\": \"EventBridgeLifecycle\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"events:PutRule\",\n \"events:DeleteRule\",\n \"events:PutTargets\",\n \"events:RemoveTargets\",\n \"events:TagResource\",\n \"events:UntagResource\"\n ],\n \"Resource\": \"arn:aws:events:*:*:rule/awsmp-*\"\n },\n {\n \"Sid\": \"BoundaryPolicyRead\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"iam:GetPolicy\"\n ],\n \"Resource\": \"arn:aws:iam::*:policy/awsmp-metering-boundary\"\n },\n {\n \"Sid\": \"ServiceReadOnly\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"apigateway:GET\",\n \"dynamodb:DescribeContinuousBackups\",\n \"dynamodb:DescribeTable\",\n \"dynamodb:DescribeTimeToLive\",\n \"dynamodb:GetItem\",\n \"dynamodb:ListTagsOfResource\",\n \"dynamodb:Query\",\n \"events:DescribeRule\",\n \"iam:GetRole\",\n \"iam:GetRolePolicy\",\n \"iam:ListRoleTags\",\n \"lambda:GetFunction\",\n \"lambda:GetFunctionConfiguration\",\n \"lambda:ListTags\",\n \"sqs:GetQueueAttributes\",\n \"sqs:ListQueueTags\"\n ],\n \"Resource\": [\n \"arn:aws:lambda:*:*:function:awsmp-*\",\n \"arn:aws:dynamodb:*:*:table/awsmp-*\",\n \"arn:aws:dynamodb:*:*:table/awsmp-*/index/*\",\n \"arn:aws:sqs:*:*:awsmp-*\",\n \"arn:aws:events:*:*:rule/awsmp-*\",\n \"arn:aws:iam::*:role/awsmp-*\",\n \"arn:aws:apigateway:*::/restapis*\",\n \"arn:aws:apigateway:*::/tags/*\"\n ]\n },\n {\n \"Sid\": \"CloudFormationListStacks\",\n \"Effect\": \"Allow\",\n \"Action\": [\n \"cloudformation:ListStacks\"\n ],\n \"Resource\": \"*\"\n }\n ]\n}\n```\n\n\u003c/details>\n\nTo create this role, the seller can run the following. **The trust policy MUST be\nconditioned** — an unconditioned `Principal: {\"AWS\": \"...:root\"}` lets ANY\nprincipal in the account assume the deployer. The DEFAULT below targets a **headless-agent\ncaller**: a named role-ARN principal + an `sts:ExternalId` condition, and **no MFA** (a\nheadless agent cannot satisfy MFA). For a **human operator** you MAY additionally require\nMFA (`\"Bool\": {\"aws:MultiFactorAuthPresent\": \"true\"}`) — that is a human-only variant, not\nthe default. This role is commonly assumed **cross-account** (the caller's account assuming\ninto the seller's account); in that case the caller's own principal ALSO needs an\n`sts:AssumeRole` grant on this role ARN (both sides), in addition to the trust policy below.\nFirst create the permissions boundary the `iam:CreateRole` statement requires:\n\n```bash\n# 1. Create the permissions boundary as a MANAGED policy (a boundary MUST be managed,\n# never an inline put-role-policy). This caps what any awsmp-* role can do.\naws iam create-policy --policy-name awsmp-metering-boundary \\\n --policy-document file://boundary.json\n\n# 2. Create the deployer role with a CONDITIONED trust policy (NOT open :root).\n# DEFAULT = headless agent: role-ARN principal + sts:ExternalId, NO MFA.\naws iam create-role --role-name MarketplaceMeteringDeployer --assume-role-policy-document '{\n \"Version\": \"2012-10-17\",\n \"Statement\": [{\n \"Effect\": \"Allow\",\n \"Principal\": {\"AWS\": \"arn:aws:iam::\u003cCALLER_ACCOUNT_ID>:role/\u003cAGENT_CALLER_ROLE>\"},\n \"Action\": \"sts:AssumeRole\",\n \"Condition\": {\n \"StringEquals\": {\"sts:ExternalId\": \"\u003cCHOOSE_A_SECRET_EXTERNAL_ID>\"}\n }\n }]\n}'\n# (Human-operator variant only: add \"Bool\": {\"aws:MultiFactorAuthPresent\": \"true\"} to Condition.)\naws iam put-role-policy --role-name MarketplaceMeteringDeployer --policy-name DeployPolicy --policy-document file://policy.json\n```\n\n> **Permissions boundary — REQUIRED before deployment, and the names MUST match.**\n> The deployer policy's `iam:CreateRole` is gated on\n> `iam:PermissionsBoundary = arn:aws:iam::*:policy/awsmp-metering-boundary`. IAM only\n> populates the `iam:PermissionsBoundary` request key when a boundary is actually attached,\n> so **any `awsmp-*` role created without that exact boundary is denied with AccessDenied.**\n> SAM auto-generates the Register/Meter/Subscription Lambda execution roles from the inline\n> `Policies` in the templates, so the templates set\n> `Globals.Function.PermissionsBoundary: arn:aws:iam::${AWS::AccountId}:policy/${PermissionsBoundaryName}`\n> (parameter `PermissionsBoundaryName`, default `awsmp-metering-boundary`) on those\n> generated roles. The boundary policy MUST already exist in the account before you run\n> `deploy.sh`, and its name MUST equal both the deployer condition and the\n> `PermissionsBoundaryName` parameter. If you rename it, update all three.\n>\n> A boundary that caps the generated Lambda roles to exactly what this skill's Lambdas use\n> (`boundary.json`):\n>\n> ```json\n> {\n> \"Version\": \"2012-10-17\",\n> \"Statement\": [\n> {\n> \"Sid\": \"MarketplaceMeteringRuntime\",\n> \"Effect\": \"Allow\",\n> \"Action\": [\n> \"aws-marketplace:BatchMeterUsage\",\n> \"aws-marketplace:ResolveCustomer\"\n> ],\n> \"Resource\": \"*\"\n> },\n> {\n> \"Sid\": \"DynamoDBRuntime\",\n> \"Effect\": \"Allow\",\n> \"Action\": [\n> \"dynamodb:GetItem\",\n> \"dynamodb:PutItem\",\n> \"dynamodb:UpdateItem\",\n> \"dynamodb:Query\"\n> ],\n> \"Resource\": [\n> \"arn:aws:dynamodb:*:*:table/awsmp-*\",\n> \"arn:aws:dynamodb:*:*:table/awsmp-*/index/*\"\n> ]\n> },\n> {\n> \"Sid\": \"SqsRuntime\",\n> \"Effect\": \"Allow\",\n> \"Action\": [\n> \"sqs:SendMessage\",\n> \"sqs:ReceiveMessage\",\n> \"sqs:DeleteMessage\",\n> \"sqs:GetQueueAttributes\"\n> ],\n> \"Resource\": \"arn:aws:sqs:*:*:awsmp-*\"\n> },\n> {\n> \"Sid\": \"SnsRuntime\",\n> \"Effect\": \"Allow\",\n> \"Action\": [\n> \"sns:Publish\"\n> ],\n> \"Resource\": \"arn:aws:sns:*:*:awsmp-*\"\n> },\n> {\n> \"Sid\": \"LogsRuntime\",\n> \"Effect\": \"Allow\",\n> \"Action\": [\n> \"logs:CreateLogGroup\",\n> \"logs:CreateLogStream\",\n> \"logs:PutLogEvents\"\n> ],\n> \"Resource\": \"arn:aws:logs:*:*:log-group:/aws/lambda/awsmp-*:*\"\n> },\n> {\n> \"Sid\": \"KmsRuntime\",\n> \"Effect\": \"Allow\",\n> \"Action\": [\n> \"kms:Decrypt\",\n> \"kms:GenerateDataKey\"\n> ],\n> \"Resource\": \"*\",\n> \"Condition\": {\n> \"StringLike\": {\n> \"kms:ViaService\": [\n> \"dynamodb.*.amazonaws.com\",\n> \"sqs.*.amazonaws.com\",\n> \"sns.*.amazonaws.com\"\n> ]\n> }\n> }\n> },\n> {\n> \"Sid\": \"DenyTamperingWithOwnGuardrails\",\n> \"Effect\": \"Deny\",\n> \"Action\": \"iam:*\",\n> \"Resource\": [\n> \"arn:aws:iam::*:policy/awsmp-metering-boundary\",\n> \"arn:aws:iam::*:role/MarketplaceMeteringDeployer\",\n> \"arn:aws:iam::*:policy/MarketplaceMeteringDeployer*\"\n> ]\n> }\n> ]\n> }\n> ```\n>\n> The final `DenyTamperingWithOwnGuardrails` statement is **defense in depth** (Issue-3 guardrail\n> immutability): an explicit `Deny` on `iam:*` against the boundary policy ARN and the\n> `MarketplaceMeteringDeployer` role/policy ARNs. A boundary'd role (and any boundary'd principal)\n> therefore CANNOT create a new boundary version, detach/weaken the boundary, or attach/modify the\n> deployer role's policies — even if some other attached policy would otherwise allow it (an\n> explicit `Deny` always wins). This complements the skill's MANDATORY response rule (SKILL.md) that\n> refuses in-session requests to weaken these guardrails, so the protection does not rely on the\n> prompt alone.\n>\n> Note: the boundary does NOT grant permissions — it caps them. The role's own inline\n> policy still governs effective access; the boundary just ensures a self-created `awsmp-*`\n> role can never exceed this action set. Each statement is also resource-scoped to\n> `awsmp-*` (DynamoDB tables + their indexes, SQS queues, SNS topics, and the\n> `/aws/lambda/awsmp-*` log groups), and `kms:Decrypt`/`kms:GenerateDataKey` are constrained\n> by a `kms:ViaService` condition so keys can only be used through DynamoDB, SQS, and SNS. It\n> deliberately omits `dynamodb:Scan`, `dynamodb:DeleteItem`, and any `iam:*`, so a boundary'd\n> role cannot broaden its own DynamoDB access or escalate via IAM.\n>\n> **Important:** Do NOT use an admin role. Create a dedicated role with only the permissions above. Using admin credentials violates least-privilege and poses a security risk if the agent environment is compromised.\n>\n> **Key Facts — Dimension Validation (ALWAYS mention when asked about new dimensions):**\n>\n> - Call `aws marketplace-catalog describe-entity --entity-id \u003cProductId> --catalog AWSMarketplace`\n> - Check that dimension appears with `ExternallyMetered` type — case-sensitive match required\n> - If the dimension is not a defined pricing dimension for the product, BatchMeterUsage REJECTS the record with InvalidUsageDimensionException (not billed)\n> - Verify dimension keys against the Catalog API BEFORE deploying the metering Lambda\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":32675,"content_sha256":"7bb6a3f304996ce1033c5a080662224ba4eb6fc9fd55b18d9742754e9ab90b56"},{"filename":"references/query-path.md","content":"# Query Path — Find, Debug, and Analyze Metering Records\n\n## What this query path is (and is not)\n\nThe query path is a **fast, zero-setup, recent (≤90 days), single-region, single-event-stream\noperational lens over the metering SUBMISSIONS you sent to `BatchMeterUsage`** — it answers\ndebugging/triage questions from CloudTrail Event History. It reflects what was *submitted* and\nhow the API responded — the per-record `results[].status` values `Success`/`DuplicateRecord`/`CustomerNotSubscribed` (CloudTrail lowercases response field names; the direct SDK response is PascalCase `Results[].Status`), and request-level exceptions like `InvalidUsageDimensionException` that failed a whole call — including rejected/duplicate records that were **not** billed.\n\n**USES (the skill queries this directly):**\n\n- CloudTrail Event History (`LookupEvents`) in the seller's metering region, last ≤90 days,\n page-bounded and processed in memory (see \"Result completeness\" below).\n- BASIC queries only: lookups and simple filters/counts/summaries by customer, dimension,\n status, or time range (the query types below).\n\n**RECOMMENDS (out of scope for this query — hand off, do not run here):**\n\n| The seller wants… | Why CloudTrail can't be the answer | Recommend instead |\n|---|---|---|\n| Billed totals / \"what was I actually paid\" / authoritative sums | CloudTrail is the *submissions* view (includes rejected/duplicate), not billing-of-record | **Seller Reports** (AMMP Insights / SDDS) — billed amounts; reconcile per \"Totals don't match\" below |\n| Data older than 90 days / history | CloudTrail Event History retains only 90 days | **Seller Reports / SDDS** — seller-run (one-time \"Set up customer data storage\"; the seller queries the delivered feeds) |\n| Per-product totals with no LicenseArn→productCode mapping | CA `BatchMeterUsage` events carry no `productCode` | Explain the limitation; offer only non-product-scoped queries |\n| Complex/open-ended analytics (forecasting, arbitrary aggregations, cross-source joins) | Determined by the host agent + model, not the skill | Hand the fetched data to the host agent, or the seller's own analytics |\n| An authoritative full-window total for a high-volume seller (fetch would truncate) | The fetch is page-bounded (see below) | Narrow window / raise `--max-pages`, or use **Seller Reports** |\n\nWhen a question lands outside the USES scope, say so — don't return a bounded, submissions-only\nanswer as if it were the authoritative/billed total.\n\n## CloudTrail-derived SQL analytics is the WRONG source for billing/usage analytics\n\nAny CloudTrail-based path — CloudTrail **Lake** OR **Athena over a (self-managed) CloudTrail\n`trail → S3` export** — analyzes **SUBMISSIONS only**: it includes rejected / `DuplicateRecord`\nrecords (**NOT billed**), is **regional**, and for Concurrent Agreements carries **no\n`productCode`** (no product attribution). So it is the wrong source for billing/usage analytics.\n\n- **Do NOT set up CloudTrail Lake / `create-event-data-store` for metering** — not for analytics\n and not for lookups.\n- **For complex SQL analytics, billing-grade totals, or data older than 90 days, use SDDS data\n feeds:** AWS pushes daily CSV (~midnight UTC, prior-day 24h) to your encrypted S3 bucket and\n you run your **OWN Athena/ETL** over the bi-temporal feeds. SDDS is **enrollment-forward (NO\n backfill)**, **push-only** (no on-demand query/trigger API), and enrolled via a one-time\n **MANUAL AMMP step** (\"Set up customer data storage\"). The skill **RECOMMENDS but does not\n run** it. (Billed history for any range with no setup is also in the console \"Billed Revenue\"\n dashboard — see `references/seller-reports.md`.)\n- A self-managed CloudTrail **`trail → S3`** (never Lake) + Athena is acceptable **ONLY for\n long-term raw-SUBMISSION AUDIT retention** (from setup forward, no backfill) — NOT as a\n billing/usage-analytics source. The destination bucket stores raw `BatchMeterUsage`/\n `ResolveCustomer` submission data, so it **MUST** be encrypted at rest with **SSE-KMS**\n (`KMSKeyId` on the trail, a customer-managed key) and enforce **encryption in transit** with\n a bucket policy denying `aws:SecureTransport=false`; also block public access and restrict\n read to the auditing principals.\n- **Do NOT reference the deprecated `GenerateDataSet`.**\n\n## Step 1: Determine the Seller's Metering Region\n\n**You MUST ask the user which region their application calls BatchMeterUsage from.** CloudTrail Event History is regional — you MUST query the same region where the API was invoked.\n\nAsk the seller which region their application calls BatchMeterUsage from — do not assume a fixed set. You must query CloudTrail in that same region.\n\n## Step 2: Extract Query Parameters\n\nParse the user's query to identify filters:\n\n| Parameter | Description | Example |\n|-----------|-------------|---------|\n| `customer_id` | CustomerAWSAccountId | `123456789012` |\n| `status` | Result status | `Success`, `CustomerNotSubscribed`, `DuplicateRecord` |\n| `dimension` | Usage dimension name | `fgt_cnf_hours` |\n| `product_code` | Product to scope a product-specific query to (needs a mapping — see below) | `prod-abc123` |\n| `start_date` / `end_date` | Date range (YYYY-MM-DD) | `2025-03-01` |\n| `query_type` | Type of query | `summary`, `detail`, `count`, `list_failures`, `top_customers` |\n| `top_n` | Number of top results | `10` |\n\n## Step 3: Run the Query Script\n\n```bash\npython3 ./scripts/query_metering.py \\\n --region \u003cSELLER_METERING_REGION> \\\n [--days N] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] \\\n [--customer-id CUSTOMER_AWS_ACCOUNT_ID] [--status STATUS] \\\n [--dimension DIMENSION] [--query-type summary|detail|count|list_failures|top_customers] \\\n [--product-code PRODUCT_CODE] [--deployment-product-code PRODUCT_CODE] \\\n [--top-n N] [--limit N] [--max-pages N]\n```\n\nIf `--start-date` is omitted, defaults to last 7 days.\n\n### Queries older than 90 days are out of scope\n\nCloudTrail Event History (the only source this script queries) retains **90 days**. Do NOT\nattempt to query older data with this tool. If the requested range is entirely older than 90\ndays, the script does not query — it returns a recommendation to use the **seller-run Seller\nReports / SDDS path** (`references/seller-reports.md`): the seller completes the one-time\n\"Set up customer data storage\" step in AMMP and runs their own query/ETL over the delivered\nS3 feeds. If the range only partly predates 90 days, the script queries the in-window portion\nand notes that the older portion needs Seller Reports / SDDS.\n\n### Product-specific queries need a LicenseArn→productCode mapping\n\n`BatchMeterUsage` for Concurrent Agreements (LicenseArn-based) carries **no** `productCode`, so\na CloudTrail event cannot be attributed to a product from the API fields alone. Before a\nproduct-specific query (`--product-code`), establish a mapping from one of these, in order:\n\n1. **Single-product deployment context** — pass `--deployment-product-code \u003cproductCode>` when\n the events come from a single-product stack (`awsmp-\u003cproductCode>-metering`/`-usage`).\n2. **CloudTrail `additionalEventData`** — if the event carries a LicenseArn→productCode map, the\n script uses it automatically.\n3. **Subscribers table** — the `productCode` attribute per licenseArn row can serve as the map.\n\nIf none is available, the script does NOT support the product-specific query: it explains why\nand offers only non-product-scoped queries (by customer, dimension, status, or time range).\nLegacy ProductCode-based metering carries `ProductCode` at the request level, so product\nattribution is available directly.\n\n## Step 4: Interpret Results\n\n| query_type | Output |\n|-----------|--------|\n| summary | Total records, quantity, breakdowns by status/dimension, top customers |\n| top_customers | Ranked list by usage quantity |\n| detail | Individual records (capped by --limit) |\n| count | Record and quantity totals |\n| list_failures | Only non-Success records |\n\n## Query Type Selection Guide\n\n| User Question | query_type | Flags |\n|--------------|------------|-------|\n| \"top 10 buyers\" | `top_customers` | `--top-n 10` |\n| \"total usage this month\" | `summary` | `--days 30` |\n| \"how many failures\" | `list_failures` | |\n| \"records for customer X\" | `detail` | `--customer-id X` |\n| \"usage by dimension\" | `summary` | (dimension breakdown included) |\n\n## Result completeness (bounded, in-memory fetch)\n\nThe query fetches CloudTrail Event History with `LookupEvents`, paginating up to `--max-pages`\n(default 20 pages × 50 = ~1000 events), then filters and aggregates **in memory**. A single\nquery is a **bounded window**, not an exhaustive scan — a high-volume seller's broad window can\nhold more events than the cap fetches.\n\nEvery result therefore carries explicit completeness signals:\n\n- `complete: true` — the whole requested window was fetched (`truncated: false`, `windowClipped: false`).\n- `truncated: true` — the page cap was hit with more events available; the result is a\n **partial sample**. `fetch.pagesFetched` / `fetch.eventsProcessed` show how much was read.\n- `windowClipped: true` — the requested start predates the 90-day CloudTrail retention limit,\n so the result covers only the last 90 days (a SHORTER range than requested); the output states\n the covered start date. `complete` is `false` whenever the result is truncated OR clipped.\n\n**Confirm completeness before presenting a result (double-pass).** Because this skill is run by\nan AI agent, always inspect these signals on the query output before answering. If `complete` is\nfalse, either (a) re-run to get a complete answer where feasible — narrow/adjust the range for a\nclip, or raise `--max-pages` / add `--customer-id`/`--dimension`/`--status` for truncation — and\nconfirm the re-run is `complete: true`; or (b) present the result while explicitly stating it is\npartial (what was clipped/truncated and the covered range) and pointing to **Seller Reports** for\nthe authoritative or older portion. For the aggregate query types (`summary`, `count`,\n`top_customers`) a truncated or clipped result carries an `aggregateWarning` — the numbers are a\n**lower bound**, not authoritative. Never report a partial result as covering the full requested\nrange, and never quote a partial aggregate as a definitive total.\n\n## Status Explanations\n\n| Status | Meaning | Action |\n|--------|---------|--------|\n| `Success` | Record accepted and billed | None needed |\n| `CustomerNotSubscribed` | No active subscription | ⚠️ Do NOT retry |\n| `DuplicateRecord` | First-write-wins: original quantity stays billed; a resubmit with a *different* quantity is NOT billed | Safe only for an identical retry; do NOT rely on it to correct a quantity (under-billing risk) |\n\nRequest-level exceptions (fail the whole `BatchMeterUsage` call — NOT per-record `Results` statuses; the pipeline isolates the offending record by bisection so co-batched records still meter):\n\n- `InvalidUsageDimensionException` — a dimension not defined in the catalog (fix the dimension key / define it in AMMP; isolated to the aggregated record as `meteringStatus = Error` with `meteringStatusReason = InvalidUsageDimensionException` — a SERVER-SIDE failure, NOT `RejectedClientSide` — surfaced on the `BatchMeterUsageException` metric).\n- `TimestampOutOfBoundsException` — a record older than 24 hours (fix the timestamp).\n\n## Data Tiers\n\n| Tier | Retention | Cost | Use When |\n|------|-----------|------|----------|\n| CloudTrail Event History | 90 days | Free | Real-time, last 90 days |\n| CloudTrail trail → S3 | Indefinite (your S3 lifecycle) | Low (S3) | Durable raw API-submission audit BEYOND 90 days |\n| Seller Reports (AMMP) | Full history | Free | Billed amounts, reconciliation |\n| DDB PITR Export | Configurable | Low | Full history from deployed stack |\n| SDDS (Seller Delivery Data Feeds) | Full history | Free | SQL over S3-delivered feeds (>90 days, billing) |\n\n**\"Totals don't match disbursement?\"** — CloudTrail shows ALL (including DuplicateRecord + rejected). Seller Reports show only billed. Difference = rejected/duplicate.\n\n## Troubleshooting\n\n**\"No records found\"** — Wrong region, too soon (\u003c15 min), wrong filter, or beyond 90 days.\n\n**\"No errors but not billing\"** — Dimension mismatch (case-sensitive, no runtime error).\n\n**Script slow** — Use `--max-pages`, `--limit`, or narrow date range.\n\n> For full CloudTrail CLI patterns and pagination examples, load `references/query-patterns.md`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12570,"content_sha256":"0a43a4783135185559d43747ed59067ed6923f91bea979154e120263add13358"},{"filename":"references/query-patterns.md","content":"## Common Patterns\n\n**Top 10 buyers by usage (last 30 days):**\n\n```bash\npython3 ./scripts/query_metering.py --region us-east-1 --days 30 --query-type top_customers --top-n 10\n```\n\n**Total usage for last 7 days:**\n\n```bash\npython3 ./scripts/query_metering.py --region us-east-1 --query-type summary\n```\n\n**All failures last 7 days:**\n\n```bash\npython3 ./scripts/query_metering.py --region us-east-1 --query-type list_failures\n```\n\n**Specific customer detail:**\n\n```bash\npython3 ./scripts/query_metering.py --region us-east-1 --customer-id 123456789012 --query-type detail\n```\n\n**Count by dimension:**\n\n```bash\npython3 ./scripts/query_metering.py --region us-east-1 --dimension fgt_cnf_hours --query-type count\n```\n\n## Data Tiers — Where Metering Data Lives\n\nAWS Marketplace metering data is available in multiple tiers. Choose based on your needs:\n\n| Tier | Latency | Retention | Cost | Shows |\n|------|---------|-----------|------|-------|\n| 1. CloudTrail Event History | ~15 min | 90 days | Free (always on) | Submitted records (raw API calls) |\n| 2. Seller Reports (AMMP) | ~24 hours | Full history | Free | Billed records with revenue data |\n| 3. DDB Metering Table (PITR export) | Real-time | Configurable | Low (DDB + S3 storage) | All submitted records from deployed stack |\n| 4. CloudTrail trail → S3 | ~5-15 min | Indefinite (your S3 lifecycle) | Low (S3 storage; first trail is free) | Raw `BatchMeterUsage`/`ResolveCustomer` API events (the same submissions view as Event History, but retained beyond 90 days) |\n\n### \"I want a durable record of submissions beyond Event History — what do I set up?\"\nTwo complementary options (either or both), depending on whether you want the **raw API-call audit record** or the **pipeline's stored records**:\n\n- **CloudTrail trail to S3** (raw API audit record): create a CloudTrail **trail** (management events) delivering to an S3 bucket in the seller's metering region. This captures every `BatchMeterUsage`/`ResolveCustomer` call — the SAME submissions view as Event History — but retained INDEFINITELY per your S3 lifecycle (Event History alone only keeps 90 days). Query the delivered logs with Athena. This is the durable, low-cost audit trail for \"what did I submit\" beyond 90 days. (Use a plain trail-to-S3, NOT CloudTrail **Lake** — Lake adds cost/setup with no benefit here.)\n- **DDB PITR export to S3** (pipeline's records): if the seller deployed the metering stack, export the `aggregated_usage` table via DynamoDB PITR to S3 and query with Athena — full history of the records the pipeline persisted (see Tier 3 below).\nNote: neither is BILLED revenue — for billed/disbursed amounts use Seller Reports (Tier 2). CloudTrail (trail or Event History) shows ALL submissions incl. rejected/duplicate.\n\n### When to use which tier:\n\n- **\"What did I submit in the last hour/day/week?\"** → CloudTrail Event History (zero setup, ~15 min latency)\n- **\"What happened 6 months ago?\"** → Seller Reports, DDB PITR export, or a CloudTrail trail-to-S3 (if one was set up before then)\n- **\"Durable record of submissions beyond the 90-day Event History?\"** → CloudTrail trail to S3 (raw API audit) and/or DDB PITR export to S3 (pipeline records)\n- **\"What was actually billed?\"** → Seller Reports (disbursement/revenue data)\n- **\"Why don't my totals match the disbursement report?\"** → CloudTrail shows ALL submitted (including DuplicateRecord + CustomerNotSubscribed). Seller Reports show only successfully billed. The difference = rejected/duplicate records.\n- **\"I need SQL queries on historical data\"** → SDDS (Seller Delivery Data Feeds — feeds delivered to your S3 bucket, SQL-queryable via Athena) or Seller Reports.\n\n### CloudTrail Event History (Tier 1 — zero setup, free)\n\nUse this for real-time queries (last 90 days, ~15 min delay). Event History is ALWAYS ON — do\nNOT use CloudTrail **Lake** / `create-event-data-store` for metering lookups (no data store is\nneeded; Lake adds cost and setup for no benefit here):\n\n```bash\naws cloudtrail lookup-events \\\n --lookup-attributes AttributeKey=EventSource,AttributeValue=metering-marketplace.amazonaws.com \\\n --region \u003cSELLER_METERING_REGION> \\\n --start-time $(date -d '7 days ago' -u +%Y-%m-%dT%H:%M:%SZ) \\\n --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \\\n --max-results 50\n```\n\n**Key facts:**\n\n- **90-day retention limit** — cannot query older than 90 days (for older, recommend Seller Reports / SDDS — do NOT use CloudTrail Lake)\n- **50 events per page** — use `--next-token` from response to paginate (see Pagination section below)\n- **Regional** — MUST query the same region where your application calls BatchMeterUsage\n- **~15 minute delivery latency** — events appear ~15 min after the API call (NOT 24-48 hours)\n- Filter by `eventSource=metering-marketplace.amazonaws.com` (CloudTrail eventSource — note the hyphen; distinct from the CLI service name `meteringmarketplace`)\n- To verify a submission, read each event's CloudTrail `responseElements` — check **`results`** (per-record `status`) vs **`unprocessedRecords`** FIRST; a record in `unprocessedRecords` was not accepted and should be retried. CloudTrail lowercases response field names (`results`/`status`/`meteringRecordId`/`unprocessedRecords`); the direct SDK `BatchMeterUsage` response is PascalCase (`Results`/`Status`/`MeteringRecordId`/`UnprocessedRecords`).\n\n### Seller Reports (Tier 2 — full billing history, free)\n\nAccess via [AWS Marketplace Management Portal (AMMP)](https://aws.amazon.com/marketplace/management/reports/):\n\n- **Revenue reports** — billed amounts per customer per dimension\n- **Disbursement reports** — payouts received\n- **Monthly billed revenue** — aggregated billing data\n- **24-hour SLA** — data available within 24 hours of billing cycle close\n\nUse Seller Reports when:\n\n- You need historical data beyond 90 days\n- You need to reconcile billed amounts (not raw submissions)\n- You want free access without additional infrastructure\n\n### DDB Metering Table with PITR Export (Tier 3 — low cost)\n\nIf the seller deployed the metering stack (from the `setting-up-marketplace-metering` skill), their DynamoDB `MeteringRecords` table stores all submitted records with Point-in-Time Recovery enabled:\n\n- **Export to S3**: Use DynamoDB PITR export to create a queryable S3 dataset\n- **Query with Athena**: Set up a Glue table on the exported data for SQL queries\n- **Cost**: DDB storage + S3 storage + Athena per-query (typically pennies/month for moderate volume)\n\nUse DDB PITR when:\n\n- Seller already has the deployed stack (data is already there)\n- Need full submitted record history (not just billed)\n- Prefer lower-cost SQL over exported records\n\n### CloudTrail Trail to S3 (Tier 4 — durable raw API audit beyond 90 days)\n\nCloudTrail **Event History** (Tier 1) is always on but only retains **90 days**. For a durable\nrecord of the raw `BatchMeterUsage`/`ResolveCustomer` API calls beyond that, create a CloudTrail\n**trail** that delivers events to an S3 bucket:\n\n- **Set up a trail** (per metering region, or a multi-region/organization trail) capturing\n **management events** to an S3 bucket you own. The `metering-marketplace` API calls are\n management events, so no data-event config is needed. The first trail per account is free;\n you pay only S3 storage (apply an S3 lifecycle policy to age/expire objects).\n- **Query with Athena**: point an Athena/Glue table at the delivered CloudTrail S3 prefix and\n filter `eventsource = 'metering-marketplace.amazonaws.com'` — the SAME submissions view as\n Event History (`results[].status`, `unprocessedRecords`, request-level exceptions), but with\n the retention YOU choose.\n- Use a plain **trail → S3**, NOT CloudTrail **Lake** (`create-event-data-store`) — Lake adds\n cost and setup for no benefit for this audit use.\n\nUse a CloudTrail trail to S3 when:\n\n- You want a durable, self-owned audit record of submissions beyond the 90-day Event History\n- You want the RAW API-call record (not the pipeline's stored records, which is Tier 3 DDB PITR)\n- You may want both this and DDB PITR — they are complementary (API audit vs. pipeline records)\n\n## CloudTrail Query Patterns (Real-Time, Last 90 Days)\n\nUse these patterns when the seller needs real-time data (last 90 days). **MUST use the region where BatchMeterUsage is called** (ask the seller — do not assume us-east-1).\n\n### Find failed submissions (last 7 days)\n\n```bash\naws cloudtrail lookup-events \\\n --lookup-attributes AttributeKey=EventSource,AttributeValue=metering-marketplace.amazonaws.com \\\n --region \u003cSELLER_METERING_REGION> \\\n --start-time $(date -d '7 days ago' -u +%Y-%m-%dT%H:%M:%SZ) \\\n --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \\\n --max-results 50 --output json | \\\npython3 -c \"\nimport sys, json\ndata = json.load(sys.stdin)\nfor event in data.get('Events', []):\n ce = json.loads(event.get('CloudTrailEvent', '{}'))\n results = ce.get('responseElements', {}).get('results', [])\n for r in results:\n if r.get('status') != 'Success':\n ur = r.get('usageRecord', {})\n print(f\\\" {r['status']} | account={ur.get('customerAWSAccountId','?')} dim={ur.get('dimension','?')} qty={ur.get('quantity','?')}\\\")\n\"\n```\n\nLook for these failure statuses:\n\n- `CustomerNotSubscribed` — customer's subscription is inactive, do NOT retry\n- `DuplicateRecord` — first-write-wins and REPORTED; the original quantity stays billed. A resubmit with a different quantity is NOT billed (under-billing risk). Not simply \"benign\"; safe only for an identical retry\n- `TimestampOutOfBoundsException` — timestamp older than 24 hours (rejects entire batch)\n\n### Top N buyers by usage (from CloudTrail)\n\n```bash\naws cloudtrail lookup-events \\\n --lookup-attributes AttributeKey=EventSource,AttributeValue=metering-marketplace.amazonaws.com \\\n --region \u003cSELLER_METERING_REGION> \\\n --start-time $(date -d '30 days ago' -u +%Y-%m-%dT%H:%M:%SZ) \\\n --max-results 50 --output json | \\\npython3 -c \"\nimport sys, json\nfrom collections import Counter\ndata = json.load(sys.stdin)\nusage = Counter()\nfor event in data.get('Events', []):\n ce = json.loads(event.get('CloudTrailEvent', '{}'))\n for record in ce.get('requestParameters', {}).get('usageRecords', []):\n acct = record.get('customerAWSAccountId', 'unknown')\n qty = record.get('quantity', 0)\n usage[acct] += qty\nprint('Top 10 buyers by usage:')\nfor acct, total in usage.most_common(10):\n print(f' {acct}: {total} units')\n\"\n```\n\n**Note:** This only covers one page (50 events). For complete results, paginate with `--next-token` (see Pagination section) or use `--max-pages` with the query script.\n\n### Records for a specific buyer (by CustomerAWSAccountId)\n\n```bash\naws cloudtrail lookup-events \\\n --lookup-attributes AttributeKey=EventSource,AttributeValue=metering-marketplace.amazonaws.com \\\n --region \u003cSELLER_METERING_REGION> \\\n --start-time $(date -d '7 days ago' -u +%Y-%m-%dT%H:%M:%SZ) \\\n --max-results 50 --output json | \\\npython3 -c \"\nimport sys, json\nTARGET = '123456789012' # replace with buyer's AWS account ID\ndata = json.load(sys.stdin)\nfor event in data.get('Events', []):\n ce = json.loads(event.get('CloudTrailEvent', '{}'))\n for record in ce.get('requestParameters', {}).get('usageRecords', []):\n if record.get('customerAWSAccountId') == TARGET:\n print(f\\\" {event['EventTime']} | dim={record.get('dimension')} qty={record.get('quantity')} licenseArn={record.get('licenseArn','N/A')}\\\")\n for result in ce.get('responseElements', {}).get('results', []):\n ur = result.get('usageRecord', {})\n if ur.get('customerAWSAccountId') == TARGET:\n print(f\\\" → status={result.get('status')} meteringRecordId={result.get('meteringRecordId','N/A')}\\\")\n\"\n```\n\n### Total usage by dimension (from CloudTrail)\n\n```bash\naws cloudtrail lookup-events \\\n --lookup-attributes AttributeKey=EventSource,AttributeValue=metering-marketplace.amazonaws.com \\\n --region \u003cSELLER_METERING_REGION> \\\n --start-time $(date -d '30 days ago' -u +%Y-%m-%dT%H:%M:%SZ) \\\n --max-results 50 --output json | \\\npython3 -c \"\nimport sys, json\nfrom collections import Counter\ndata = json.load(sys.stdin)\nby_dim = Counter()\nfor event in data.get('Events', []):\n ce = json.loads(event.get('CloudTrailEvent', '{}'))\n for record in ce.get('requestParameters', {}).get('usageRecords', []):\n dim = record.get('dimension', 'unknown')\n qty = record.get('quantity', 0)\n by_dim[dim] += qty\nprint('Usage by dimension:')\nfor dim, total in by_dim.most_common():\n print(f' {dim}: {total} units')\n\"\n```\n\n### Confirm a specific record was accepted\n\n```bash\naws cloudtrail lookup-events \\\n --lookup-attributes AttributeKey=EventSource,AttributeValue=metering-marketplace.amazonaws.com \\\n --region \u003cSELLER_METERING_REGION> \\\n --start-time $(date -d '1 day ago' -u +%Y-%m-%dT%H:%M:%SZ) \\\n --max-results 50 --output json | \\\npython3 -c \"\nimport sys, json\nTARGET_ID = '\u003cMETERING_RECORD_ID>' # from BatchMeterUsage response\ndata = json.load(sys.stdin)\nfor event in data.get('Events', []):\n ce = json.loads(event.get('CloudTrailEvent', '{}'))\n for result in ce.get('responseElements', {}).get('results', []):\n if result.get('meteringRecordId') == TARGET_ID:\n print(f\\\"Found: status={result['status']}\\\")\n print(f\\\" Record: {json.dumps(result['usageRecord'], default=str)}\\\")\n break\n\"\n```\n\n**Verification path (3 methods):**\n\n1. **Immediate:** Check `Results` vs `UnprocessedRecords` in the direct `BatchMeterUsage` SDK/API response (PascalCase)\n2. **After ~15 min:** Query CloudTrail (as above) to find the record by `meteringRecordId` — CloudTrail `responseElements` is lowercase (`results`/`status`/`meteringRecordId`/`unprocessedRecords`)\n3. **After billing cycle:** Check Seller Reports for the billed amount\n\n## Pagination\n\n### CloudTrail LookupEvents (50 events per page)\n\n```bash\n# First page\nRESULT=$(aws cloudtrail lookup-events \\\n --lookup-attributes AttributeKey=EventSource,AttributeValue=metering-marketplace.amazonaws.com \\\n --region \u003cSELLER_METERING_REGION> --max-results 50 --output json)\n\nTOKEN=$(echo $RESULT | python3 -c \"import sys,json; r=json.load(sys.stdin); print(r.get('NextToken',''))\")\n\n# Subsequent pages\nwhile [ -n \"$TOKEN\" ]; do\n RESULT=$(aws cloudtrail lookup-events \\\n --lookup-attributes AttributeKey=EventSource,AttributeValue=metering-marketplace.amazonaws.com \\\n --region \u003cSELLER_METERING_REGION> --max-results 50 --next-token \"$TOKEN\" --output json)\n TOKEN=$(echo $RESULT | python3 -c \"import sys,json; r=json.load(sys.stdin); print(r.get('NextToken',''))\")\ndone\n```\n\nFor large result sets (full month of data), consider:\n\n- Redirect stdout to a temp file (e.g. `> results.json`) for large result sets\n- Or use `--max-pages` to cap memory usage and run multiple bounded queries with date ranges\n\n## Troubleshooting\n\n### \"No records found\"\nThree most common causes:\n\n1. **Wrong region** — CloudTrail Event History is REGIONAL. You MUST query the same region where your application calls BatchMeterUsage.\n2. **Too soon** — ~15 minutes delivery latency. Wait at least 15 min after submission before querying CloudTrail.\n3. **Wrong filter** — Ensure `eventSource=metering-marketplace.amazonaws.com` (not `aws-marketplace` or `marketplace`).\n4. **Beyond 90 days** — Event History only retains 90 days. Use SDDS / Seller Reports for older data.\n\n### CloudTrail totals don't match disbursement report\nThis is expected. CloudTrail shows **all submitted records** — including:\n\n- `DuplicateRecord` — first-write-wins; the original quantity was billed (a differing-quantity resubmit is not billed — an under-billing risk, not simply \"benign\")\n- `CustomerNotSubscribed` — rejected, never billed\n\nSeller Reports show only **successfully billed** amounts. The difference between these two views is the rejected/duplicate records.\n\n### \"No errors but buyers are not being billed\"\nThis means your dimension name does not match exactly (case-sensitive). There is NO runtime error — usage is silently dropped. Call `aws marketplace-catalog describe-entity` to verify the exact dimension key. Common mistakes: camelCase vs snake_case, extra spaces, typos.\n\n### Script is slow or returns too much data\n\n- Use `--max-pages` to cap the number of API calls (each page = 50 events)\n- Use `--limit` to cap the number of retained records for `detail` queries\n- Use `--start-date` and `--end-date` to narrow the time window\n- For full-month exports, run bounded queries by week rather than loading 90 days at once\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":16624,"content_sha256":"2019bed50042b588305f449c32e6ad44413734de4eefd93dfd67ba402563f845"},{"filename":"references/registration-page.md","content":"# Buyer onboarding / registration page\n\n## The `RegistrationUrl` is a backend, not the buyer-facing page\n\nThe main stack outputs a `RegistrationUrl` — a raw API Gateway endpoint. When a buyer clicks\n**Set up your account** in AWS Marketplace, Marketplace redirects them to the product's\nFulfillment URL with an `x-amzn-marketplace-token` (POSTed as form data). The register Lambda\nbehind `RegistrationUrl` calls `ResolveCustomer` with that token and persists\n`CustomerAWSAccountId` + `LicenseArn` + `ProductCode` (+ any allowlisted extra fields) to the\nsubscribers table.\n\nThat backend is **not a finished onboarding experience.** The product's **Fulfillment URL\nshould point at the branded front-end page**, not at the raw `RegistrationUrl` — the\nMarketplace redirect must land the buyer's browser on a page that renders. Pointing the\nFulfillment URL straight at `RegistrationUrl` gives the buyer no page (and no branding or\nextra-field capture) and is only a bare-bones fallback for an initial smoke test. Most\nsellers want a **branded, buyer-facing registration page** — this is what the AWS\n**Serverless SaaS Integration reference architecture** and the Marketplace seller onboarding\nlab provide. Do not let a seller believe onboarding is \"done\" once the backend deploys.\n\n## Recommended pattern (branded page)\n\nA static site that is itself the **Fulfillment URL target** and sits in front of the\n`RegistrationUrl`:\n\n- **S3 + CloudFront** host the page (optionally a **custom domain** + an ACM certificate so the\n Fulfillment URL is the seller's own branded domain). **The AMMP Fulfillment URL points here.**\n- The page receives the Marketplace redirect (buyer's browser + token), renders the seller's\n branding/marketing, and **captures additional buyer fields** (e.g. contact name, email,\n company, use-case).\n- On submit it forwards the **token unchanged** plus the captured fields (as allowlisted form\n keys) to `RegistrationUrl` (the backend). The token is short-lived (~4h) and reusable until\n expiry.\n- **Secure the public page (same protections the backend `RegistrationUrl` already gets):**\n - Attach a **CloudFront response-headers policy** setting `Content-Security-Policy`,\n `Strict-Transport-Security` (HSTS), `X-Frame-Options`, and `X-Content-Type-Options` so the\n buyer-facing page carries the same security headers the register Lambda returns.\n - Front the CloudFront distribution with **AWS WAF** (a rate-based rule + the common-exploit\n managed rule set) for defense in depth on this public, unauthenticated page.\n\nThe page's markup/branding is **seller-owned** (like usage-write logic — out of scope of the\ngenerated backend), but the skill can scaffold the S3 + CloudFront (+ optional custom domain,\nresponse-headers policy, and WAF association) and wire the form contract on request. Ask the\nseller whether they want the branded-page scaffold or will host their own.\n\n## Capture \"Know Your Buyer\" (KYB) fields from the start\nThe register Lambda (in-region) persists ONLY the fields named in `ALLOWED_REGISTRATION_FIELDS`\n(comma-separated, each length-bounded, up to a max count; the token is never stored) — and it\nwrites them to the **in-region `customer-profile` table** (PK `licenseArn` + SK\n`customerAWSAccountId`), NOT the subscribers table (which is kept PII-free and lives in\nus-east-1). Because the profile table is schemaless, **no table change is needed to add a buyer\nattribute** — you only need (1) the field on the onboarding form and (2) the field name in the\nallowlist; add a GSI on the profile table only if you want to look profiles up by that field.\n\nAt initial integration, propose capturing the buyer fields the AWS Marketplace **Know Your\nBuyer (KYB)** initiative plans to collect — typically buyer **contact name, email,\ncompany/organization, and use-case** — so they are written into the customer-profile table from\nday one. Doing this up front avoids a later backfill/migration when KYB fields become required.\n\n> **This describes a NEW skill-generated integration.** For an EXISTING seller stack the skill\n> ADAPTS instead: the seller may already store registration data in their own shape, and if their\n> subscribers table already holds buyer PII, moving it to a per-Region profile table is a data\n> migration + code cutover — not a drop-in. See `references/existing-sellers.md` → \"Adding a new\n> capability to an EXISTING stack\".\n\nGuidance:\n\n- Set `ALLOWED_REGISTRATION_FIELDS` to the seller's chosen KYB + custom fields\n (e.g. `contact_name,contact_email,company,use_case`).\n- Keep the fields OPTIONAL and seller-confirmed — do not invent PII fields the seller did not\n ask for; whether a KYB field is mandatory is an AWS Marketplace program decision, not this\n skill's.\n- Buyer PII stays IN-REGION (in the customer-profile table) and OUT of logs (identifiers are\n masked on output); the token is never persisted; PII is never written to the us-east-1\n subscribers table.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":4968,"content_sha256":"76adda8799fdcb34dfe452e86cf02d6b581e639a5fe7f8d0048760389b0fa2cd"},{"filename":"references/scalable-metering-design.md","content":"# AWS Marketplace Metering Pipeline — System Design\n\nThis document describes the metering pipeline this skill generates for an AWS Marketplace\nSaaS usage-based (pay-as-you-go) product. It is the reference architecture the skill\nmaterializes into a seller's workspace and deploys; the generated CloudFormation/SAM\ntemplates (`assets/template-main.yaml`, `assets/template-events.yaml`) and Lambda handlers\n(`scripts/*.py`) implement exactly what is described here.\n\n## Overview\n\nMetering turns a seller's usage into billed revenue by submitting `UsageRecord`s to the AWS\nMarketplace `BatchMeterUsage` API. The seller writes raw usage into DynamoDB; the pipeline\ndiscovers, aggregates, and submits that usage. It is designed to scale to sellers\nwriting thousands of metering records per hour while staying within the `BatchMeterUsage` rate\nlimit and DynamoDB per-partition write limits, and to meter usage once — avoiding double-billing\nand reducing the chance of silently losing usage.\n\nThe pipeline is deployed as **two CloudFormation stacks**:\n\n- **Events stack** (`awsmp-events-stack`, us-east-1, shared by all of a seller's products):\n receives AWS Marketplace subscription lifecycle events from EventBridge and maintains the\n subscribers table. AWS Marketplace emits these events only in us-east-1.\n- **Main stack** (`awsmp-\u003cproductCode>-metering`, one per product per metering Region):\n the registration endpoint, the usage/aggregated-usage tables, and the metering pipeline\n itself. `BatchMeterUsage` is regional, so a seller deploys one main stack in each Region\n where usage occurs and meters in-Region.\n\nResource names use the fixed `awsmp-*` convention. The two-stack split, the table/index key\nschemas, and the event wiring are fixed; sellers customize only the marked configuration\nparameters (product code, Region(s), stage name, tags, retention, alarm actions).\n\n## Design principles\n\n1. **Decouple every stage.** Discovery, aggregation, submission, and per-row write-back run\n as independent Lambdas connected by SQS queues, so each scales on its own and no single\n invocation is bounded by another stage's latency.\n2. **Idempotent by construction.** Every stage can be safely re-run. A single durable commit\n point (a conditional write) plus `BatchMeterUsage`'s first-write-wins de-duplication make\n at-least-once delivery safe.\n3. **Surface failures.** Each failure mode is designed to either self-heal on the next\n scheduled run or surface on a CloudWatch alarm, so that dropped usage is accompanied by an\n operator-visible signal rather than failing silently.\n4. **Bounded load.** Second-precision timestamps bound any one aggregation group to 3,600\n rows/hour, which in turn bounds every downstream read, memory footprint, and write fan-out.\n5. **Least privilege.** Each Lambda's execution role grants only the specific actions and\n resource ARNs it uses; lookups use GSI `Query`, never `Scan`.\n\n## Data model\n\n### Subscribers table (events stack, us-east-1)\n\nOne unified table holding subscription/agreement **state**, keyed for Concurrent Agreements by\n`licenseArn` (partition) + `customerAWSAccountId` (sort). One buyer holding N concurrent\nagreements is N rows (same account, different license). Each row carries `productCode`,\n`agreementId`, `agreementStatus` (`active`/`inactive` — the agreement lifecycle),\n`subscriptionStatus` (`active`/`deprovisioning`/`inactive` — the license lifecycle), and\n`registeredRegions` (a DynamoDB String Set of Regions in which the buyer has registered — reference only,\nnever a metering gate).\n\nThe subscribers table is **PII-free**: it holds only the identifiers and lifecycle statuses\nabove. Because it carries no buyer PII, it is safe to keep in us-east-1 alongside EventBridge\neven for products whose buyers are in an opt-in Region. Two GSIs keep lookups off `Scan`:\n`customerAWSAccountId-index` (find all agreements for a buyer; the registration lookup) and\n`agreementId-index` (the subscription lookup when an agreement event omits the license ARN).\n\n### Customer-profile table (main stack, per Region)\n\nBuyer registration data (PII — contact name, email, company, use-case, and any seller-defined\nfields) is stored **in-Region** in `awsmp-\u003cproductCode>-customer-profile`, keyed by\n`licenseArn` (partition) + `customerAWSAccountId` (sort) to align with the subscriber identity.\nKeeping registration PII in the Region the buyer registered in — rather than in the shared\nus-east-1 subscribers table — lets a seller in an opt-in Region keep that Region's buyer PII in\nthat Region. The registration Lambda writes the allowlisted fields as a `registrationData` map;\na seller may promote any field to its own top-level attribute with a matching GSI for direct\nlookup. This table is not on the metering path; it is a seller-owned buyer datastore.\n\n### Usage table (main stack)\n\nThe seller writes raw usage here. Keyed by `licenseArn` (partition) + a composite\n`customerAWSAccountId#dimension#timestamp` (sort), where `timestamp` is a whole-second UTC value\n(`YYYY-MM-DDTHH:MM:SS`). Second precision lets multiple rows exist for the same\n`(licenseArn, customerAWSAccountId, dimension, hour)` — each distinct second is its own row —\nwhile bounding a group to at most 3,600 rows/hour. `customerAWSAccountId`, `dimension`, and\n`timestamp` are also stored as top-level attributes so the pipeline reads them without parsing\nthe sort key; in particular a Concurrent Agreements `UsageRecord` is built entirely from a usage\nrow, with no subscriber lookup.\n\nA sparse `metering_pending` GSI (partition = `meteringPending` hour bucket `YYYY-MM-DDTHH`,\nsort = `licenseArn`, projecting `customerAWSAccountId` + `dimension`) makes pending usage\ndiscoverable. The writer sets `meteringPending` to the row's UTC hour bucket when it writes a\nrow; the pipeline clears it once the row's aggregation group reaches a terminal state.\n\n### Aggregated-usage table (main stack)\n\n`awsmp-aggregated-usage`, keyed by `licenseArn` (partition) + `account#dimension#hour` (sort),\nis the **source of truth for submission**: one row per `(licenseArn, customerAWSAccountId,\ndimension, hour)` group, carrying the summed quantity, merged usage allocations, and its own\n`meteringPending` marker. A sparse `metering_pending` GSI (partition = `meteringPending`) lets\nthe submitter find pending records. Records are written by a conditional `PutItem`, making the\nwrite the pipeline's idempotency commit point.\n\n### Audit timestamps\n\nEvery row the pipeline writes to any of these tables carries two ISO-8601 UTC audit\nattributes: `createdAt` (set once, via `if_not_exists` on updates or on first insert) and\n`updatedAt` (refreshed on every write). These are audit/reference metadata only — not keys, not\nPII, and never a metering or billing input: they do **NOT gate metering** (metering decisions\nfollow `subscriptionStatus` / the `metering_pending` GSI, never `createdAt`/`updatedAt`). For the\nseller-written raw usage table, the seller\nis asked to stamp the same attributes; the pipeline stamps `updatedAt` whenever it finalizes a\nraw row.\n\n### Writer contract (seller-owned)\n\nIngestion — writing usage rows — is always the seller's responsibility; the pipeline never\nwrites usage. A conforming raw usage row requires:\n\n- the composite sort key `{customerAWSAccountId}#{dimension}#{YYYY-MM-DDTHH:MM:SS}` in whole\n seconds, UTC, with its account and dimension segments equal to the row's own attributes;\n- `meteringPending` equal to the UTC hour of the row's own timestamp (this is the discovery\n key — a mismatched bucket is the one writer error the pipeline cannot detect, so it is\n verified by the post-deploy hands-on test);\n- a non-negative integer `quantity`;\n- optional `usageAllocations` in the `BatchMeterUsage` `UsageAllocation` shape for Vendor\n Metered Tagging.\n\n## Architecture\n\n```\n EventBridge — 6 rules × 5 constant-Input targets = 30 invocations (one discoverer Lambda)\n ┌──────────────────────────────────────────────────────────────────────┐\n │ 23 targets: {mode: meter, hourOffset: 1..23} │\n │ 7 targets: {mode: ageout, shard: 0..6} │\n └──────────────────────────────────────────────────────────────────────┘\n │ (reserved concurrency = 30)\n ┌─────────────────┴──────────────────┐\n ▼ ▼\n ┌──────────────────────────┐ ┌──────────────────────────────────┐\n │ Discoverer — meter mode │ │ Discoverer — ageout mode │\n │ Query metering_pending │ │ shard M owns hours where │\n │ GSI for ONE hour bucket │ │ hours_back % 7 == M │\n │ = floor(now,h) - offset; │ │ REMOVE meteringPending + │\n │ enqueue 1 message/group │ │ SET meteringStatus = │\n │ (no-op if offset \u003c lock) │ │ AggregationExpired; │\n └──────────────────────────┘ │ emit UsageAggregationExpired │\n │ └──────────────────────────────────┘\n ▼ SQS: awsmp-metering-work ──► DLQ\n ┌───────────────────────────────────────────────────────────────────────┐\n │ Aggregator (SQS-triggered, high concurrency) │\n │ read group rows: Query(licenseArn, begins_with(SK, │\n │ \"account#dimension#hour\")) — ≤3600 rows, streamed │\n │ sum quantity · merge usageAllocations (≤2500) · validate structure │\n │ ├─ valid → conditional PutItem into awsmp-aggregated-usage ◄── commit point\n │ │ (meteringPending = hour) → enqueue cleanup │\n │ └─ invalid → meteringStatus = RejectedClientSide + reason + EMF │\n │ (never submitted) │\n └───────────────────────────────────────────────────────────────────────┘\n │ (cleanup) │ (submit)\n ▼ SQS: awsmp-metering-cleanup ──► DLQ ▼ EventBridge rate(5 min)\n ┌──────────────────────────────┐ ┌────────────────────────────────────┐\n │ Cleanup (SQS-triggered) │ │ Submitter (reserved = 1) │\n │ message = ≤100 raw-row keys │ │ read aggregated_usage pending (GSI) │\n │ per-key UpdateItem: │ │ oldest-first, deprovisioning-first; │\n │ REMOVE meteringPending + │ │ coalesce ≤25 → BatchMeterUsage; │\n │ SET meteringStatus = │ │ retry once, isolate a poison │\n │ Aggregated (raw table) │ │ record by bisection; │\n └──────────────────────────────┘ │ write MeteringRecordId + status + │\n │ REMOVE meteringPending on │\n │ aggregated_usage (never raw table) │\n └────────────────────────────────────┘\n\n EventBridge rate(5 min): submission-expiry Lambda sweeps aggregated_usage for records that\n can no longer be billed and marks them SubmissionExpired (emits UsageSubmissionExpired).\n```\n\n## Pipeline stages\n\n### Discoverer\n\nA single Lambda (reserved concurrency 30) invoked by 6 EventBridge rules carrying 5 constant\n`Input` targets each — 30 invocations total. Each invocation's slice identity comes only from\nits constant target `Input`, never from a wall-clock read, so slices are disjoint and\ngap-free.\n\n- **`meter` mode** (23 targets, `hourOffset` 1–23): Query the `metering_pending` GSI for the\n single hour bucket `floor(now, hour) - hourOffset` and enqueue one work message per\n `(licenseArn, customerAWSAccountId, dimension, hour)` group. It reads no group rows,\n aggregates nothing, and submits nothing. It no-ops when `hourOffset` is below the configured\n lock (see Metering lock period).\n- **`ageout` mode** (7 targets, `shard` 0–6): shard M owns lookback hours where\n `hours_back % 7 == M`. For a raw row still pending past the 24-hour billable window, it\n clears `meteringPending`, sets `meteringStatus = AggregationExpired`, and emits the\n `UsageAggregationExpired` metric — a bounded, alarmed record of usage that aged out before it\n could be aggregated.\n\nThe discoverer is the sole enqueuer and the sole age-out path, so a scheduler stall would\nsilently stop both metering and age-out. A dedicated \"did not run\" alarm (breaching on missing\ndata) catches that case.\n\n### Aggregator\n\nAn SQS-triggered Lambda that processes one group per message. It reads the group's rows with a\ntargeted `begins_with(sortKey, \"account#dimension#hour\")` Query (never a whole-partition scan),\nstreams the fold to a running sum, merges seller-provided `usageAllocations`, and runs\nstructural client-side validation.\n\nValidation is a property of aggregation, so client-side rejection lives here, not in the\nsubmitter. The pipeline is **not** configured with the product's dimension list — dimension\nvalidity is server-authoritative (`BatchMeterUsage` fails the call with a request-level `InvalidUsageDimensionException` for an\nundefined dimension), which keeps the stack decoupled from the AWS Marketplace catalog so\nadding or renaming a dimension needs no redeploy. The client-side checks are structural only:\ndimension present; whole-second sort key with matching segments; non-negative integer quantity;\ntimestamp within the window; identifier shape; allocation invariants (≤2,500 tag-sets). A group\nthat fails any check is written with `meteringStatus = RejectedClientSide` and a reason,\nemits the `UsageRecordRejected` metric, and never reaches submission.\n\nA valid group is written as one aggregated record via **conditional `PutItem`**\n(`attribute_not_exists`). This conditional write is the pipeline's commit point:\n\n- On success (a fresh write), the aggregator enqueues cleanup carrying the authoritative sum\n and row count.\n- On `ConditionalCheckFailedException` (the record already exists — a duplicate SQS delivery,\n overlapping triggers, or a late re-visit), the aggregator skips the put and does **not**\n enqueue cleanup. Because the group read only returns rows that still carry `meteringPending`,\n an already-cleaned group yields no rows and no-ops; a genuinely lost cleanup self-heals on\n the next scheduled re-visit, which re-detects the still-pending rows and cleans them. This\n keeps the cleanup queue from inflating on every duplicate visit while preserving self-healing.\n\nA zero-quantity group inside the window is deferred (submitting 0 early would lock the hour at 0\nvia first-write-wins); at the oldest processed edge it is finalized as `Quantity: 0`.\n\n### Cleanup\n\nAn SQS-triggered Lambda that clears `meteringPending` on the raw rows of an aggregated group\nand stamps their terminal status — off the submit path so submission latency is not tied to a\nlarge volume of per-row writes. Because `BatchWriteItem` cannot partially update an item,\ncleanup issues a surgical per-key `UpdateItem` from a bounded thread pool with exponential\nbackoff.\n\n- A message from a fresh aggregation write carries the `Aggregated` status, sum, and count →\n `REMOVE meteringPending` and `SET meteringStatus = Aggregated`, `meteringStatusReason =\n \"Aggregated total quantity \u003csum> from \u003ccount> raw usage records\"` (unconditional, hence\n idempotent).\n- A message without a status → `REMOVE meteringPending` only (never stamps a possibly-stale\n sum).\n\nAll rows of a group share the `licenseArn` partition (~1,000 WCU/s limit), so the global writer\ncount is bounded on two axes: the per-invocation thread pool and the cleanup queue's event\nsource `MaximumConcurrency`. Their product is kept under the per-partition limit; keys are\nbatched at ≤100 per message. A persistent failure fails the message, redelivers, and eventually\nlands on the cleanup DLQ — never a silent drop.\n\n### Submitter\n\nAn EventBridge-scheduled Lambda (`rate(5 minutes)`, reserved concurrency 1 — a single serial\nsubmitter is the safest posture against the `BatchMeterUsage` rate limit). It reads pending\n`aggregated_usage` records over `now-23h … now-1h`, ordering deprovisioning-license records\nfirst and otherwise oldest-first (closest to expiry first), and drains up to a configurable\nmaximum per run. It coalesces up to 25 records per `BatchMeterUsage` call, splitting Concurrent\nAgreements and legacy submissions, retries once, and isolates a poison record by bisection so\nits co-batched records still meter. It writes the returned `MeteringRecordId` and per-record\nstatus back to the aggregated-usage record and clears that record's `meteringPending` — never\ntouching the raw usage table. The submitter does not age records out and does not finalize\nsubscribers; those are separate stages (Submission expiry, Deprovisioning).\n\nThe default per-run maximum is API-feasible: at ≤25 records per call and the documented `BatchMeterUsage` quota of 10 requests/second per account per region, a run\ncomfortably drains a large backlog within its 5-minute schedule, so records do not age past the\n24-hour window under normal operation.\n\n### Submission expiry\n\nA scheduled Lambda (on the same `rate(5 minutes)` rule as the submitter, an independent target,\nreserved concurrency 1) that operates on `aggregated_usage` only. It terminally expires a\npending aggregated record — `REMOVE meteringPending`, `SET meteringStatus = SubmissionExpired`,\nemit `UsageSubmissionExpired` — when the record can no longer be billed: either its hour is more\nthan 24 hours in the past, or it is a previous-month record and the month-end grace has closed\n(on/after 06:00 UTC on the 1st). This is designed so that no aggregated record is left pending\nindefinitely and any submission-stage loss is alarmed — distinct from the raw table's\naggregation-stage `UsageAggregationExpired`, so an operator can tell which stage lost revenue.\n\n## Idempotency and no-loss design\n\n- **Commit point** — the conditional `PutItem` into `aggregated_usage`. A duplicate visit of\n the same group fails the condition, so the aggregator neither re-writes nor re-submits.\n- **Raw rows retain `meteringPending` until cleanup succeeds**, so a lost cleanup is\n self-healing: the rows are re-discovered on the next run and re-cleaned. The cleanup\n `UpdateItem` is idempotent, so re-cleaning is harmless.\n- **Submission is de-duplicated by AWS Marketplace.** `BatchMeterUsage` is first-write-wins per\n `CustomerAWSAccountId + LicenseArn + dimension + hour` per Region, so a re-submitted identical\n record cannot double-bill. (A resubmit with a *different* quantity returns `DuplicateRecord`\n and is not billed — an under-billing risk the writer contract calls out, not a pipeline bug.)\n- **Loss is surfaced, not silent.** Aged-out raw rows (`UsageAggregationExpired`) and expired\n aggregated records (`UsageSubmissionExpired`) are terminal, alarmed states; SQS failures\n redeliver and eventually DLQ. The intent is that any usage that cannot be metered leaves an\n operator-visible signal rather than disappearing quietly.\n\n## Subscription lifecycle and deprovisioning\n\nThe subscription Lambda (events stack) applies AWS Marketplace lifecycle events to the\nsubscribers table. Two status fields move independently and are never conflated:\n\n- `agreementStatus`: `active` (`License Updated`) → `inactive` (`Purchase Agreement Ended`).\n- `subscriptionStatus`: `active` (`License Updated`) → `deprovisioning` (`License\n Deprovisioned`) → `inactive` (finalized by the deprovision-cleanup Lambda). There is no\n `deprovisioned` value.\n\nMetering decisions follow the license lifecycle and the usage table's `metering_pending` GSI —\nnot `agreementStatus` and not registration state. `Purchase Agreement Ended` updates agreement\nstatus only; it does not stop metering or open a flush window.\n\n### Expedited deprovisioning flush\n\n`License Deprovisioned` opens a **~1-hour window** in which AWS Marketplace still accepts\n`BatchMeterUsage` for the license; after it closes, submission returns `CustomerNotSubscribed`\nand the usage is unbillable. Because the ordinary meter path defers an hour by the configured\nlock (up to 20 hours), a deprovisioning license's final usage would otherwise miss the window.\nThe pipeline expedites it, reusing the existing stages:\n\n- **Sparse deprovisioning index.** On `License Deprovisioned`, the subscription Lambda sets\n `subscriptionStatus = deprovisioning` plus a constant `deprovisioningPendingFlag` and a\n `deprovisioningExpiry` (the event time + ~1 hour), populating a sparse\n `deprovisioning-pending-index` (partition = the constant flag, sort = expiry, `KEYS_ONLY`).\n The index holds only licenses currently in a flush window and is self-emptying (the markers\n are removed at finalization), so it is queried in O(count), never scanned. Its key shape\n serves both access patterns: `Query(flag)` returns the active set; `Query(flag AND expiry ≤\n now)` returns the expired set.\n- **`flush-deprovisioning` discoverer mode**, on a dedicated `rate(5 minutes)` rule. It Queries\n the index for the active set and, for each deprovisioning license, enqueues that license's\n pending groups over `now-23h … now-1h` using a targeted per-license `metering_pending` Query\n (`meteringPending = :hb AND licenseArn = :la`, using the GSI sort key — O(deprovisioning\n groups), never a whole-partition read), **bypassing the lock**. The messages go to a\n dedicated `awsmp-deprovision-work` queue with its own aggregator event source mapping, so a\n deprovisioning backlog is isolated from — never queued behind — the regular backlog. The\n current (in-progress) hour is normally reserved for the seller's final writes, but is **also\n flushed** for a license once `now ≥ deprovisioningExpiry − 10 minutes` (within the last ~10\n minutes before that license's window closes), so its current-hour usage still traverses the\n pipeline in time. The 10-minute lead gives the 5-minute-cadence pipeline headroom to drain.\n- **Submitter prioritization.** The submitter Queries the index once per run (best-effort — a\n lookup failure falls back to the ordinary pass, never blocking submission) and reads the\n deprovisioning set's pending aggregated records in a SEPARATE first pass, submitting them ahead\n of the ordinary oldest-first backlog. That first pass reads each such license's records via a\n targeted per-license query on the aggregated-usage `metering_pending` GSI over `now-23h … now-1h`\n **and the current (offset-0) hour** (the current hour is read only for deprovisioning licenses,\n since the flush sweep enqueues their current-hour groups near window close), and is not subject\n to the ordinary read cap — so a closing-window license is neither delayed behind nor dropped by\n the ordinary backlog.\n- **Time-based finalization.** A deprovisioning license may have usage across multiple hours and\n multiple Regions (one submitter per Region, one shared subscribers table), so no single\n submitter run can know the license is fully drained — the submitter therefore does not flip it\n to `inactive`. Instead the events-stack **`awsmp-deprovision-cleanup` Lambda** (EventBridge\n Scheduler `rate(15 minutes)`) Queries the index for entries whose `deprovisioningExpiry ≤ now`\n and sets `subscriptionStatus = inactive` and removes both markers, conditioned on the flag\n still being present so concurrent runs are idempotent. Because the window is absolute, once it\n elapses no Region can meter the license, so a single us-east-1 finalization is correct for all\n Regions with no cross-region coordination.\n\nEverything the flush reuses is otherwise unchanged; non-deprovisioning licenses behave exactly\nas before. The flush enqueue produces the same work-message shape, so the conditional-put commit\nand first-write-wins de-duplication prevent any double-bill if the ordinary post-lock run later\nre-enqueues the same group. A flush that still misses the window surfaces on the existing\n`CustomerNotSubscribed` metric. Because the flush depends on the discoverer, its rule is gated\nto the full-pipeline deployment mode; the submitter prioritization and the deprovision-cleanup\nLambda apply in both modes.\n\n## Metering lock period\n\n`MeteringLockHours` (default 1, validated 1–20) sets how long a metering hour stays open for\nlate usage before it is aggregated and submitted. The discoverer's meter mode no-ops when\n`hourOffset \u003c MeteringLockHours`:\n\n- **Lock = 1 (default):** only fully-complete hours are processed. At 12:20 the discoverer\n processes 11:00–11:59:59 (offset 1), never the in-progress 12:00 hour.\n- **Lock = 6:** offsets 1–5 no-op; offset 6 is the first to aggregate the hour, giving writers a\n full six hours to send late events for that hour.\n\nAn hour is first submitted at age `MeteringLockHours`, leaving `24 − MeteringLockHours` hours\ninside the 24-hour billable window before age-out. Capping the lock at 20 leaves ≥4 hours of\nmargin after first submission — room for ≥3 hourly retries of a transient failure and for a\nseller to inspect and re-drive a client-side rejection (fix the source row; it re-meters within\nthe remaining window) before the hour ages out. The lock does not move the 24-hour age-out\nboundary. A genuinely zero-quantity group still finalizes at the oldest processed edge; because\nthe processed hours are exactly those older than the lock, the lock never defers a zero-quantity\nfinalization.\n\n## Month-boundary reconciliation\n\nAWS Marketplace accepts previous-month usage until **06:00 UTC on the 1st** of the next month\n(the month-end grace, which extends past the rolling 24-hour window for month-boundary records).\nThe per-bucket lock is therefore month-aware, classifying each bucket as current- or\nprevious-month in UTC:\n\n- **Current-month bucket** — the configured `MeteringLockHours`, always.\n- **Previous-month bucket, before 03:00 UTC on the 1st** — the configured lock, unchanged\n (the seller keeps writing late or corrected previous-month records as usual).\n- **Previous-month bucket, from 03:00 UTC on the 1st** — lock 0: on each run everything pending\n for the previous month, plus anything newly written, aggregates and submits immediately, until\n the 06:00 cutoff. The 03:00→06:00 span is the submit-and-retry margin.\n\nAge-out is suppressed for previous-month buckets while the grace is open (before 06:00 UTC on\nthe 1st), so the sweep does not expire the very records the grace preserves; after 06:00 UTC,\nprevious-month records are unbillable and normal age-out resumes. A previous-month raw record\nwritten after 03:00 UTC on the 1st must already be pre-aggregated by the seller — if the pipeline\nhas already submitted that group, a further write resubmits and returns `DuplicateRecord`\n(first-write-wins), so post-03:00 previous-month writes are the seller's risk.\n\n## Deployment modes\n\nThe pipeline supports two modes, selected at deploy time, so a seller creates only the\ncomponents their ingestion pattern needs:\n\n- **Full pipeline (default):** the seller writes raw per-second usage rows and the pipeline\n discovers, aggregates, cleans up, and submits. Deploys the raw usage table and GSI, the\n aggregated-usage table, the work/deprovision-work/cleanup queues and DLQs, and the\n discoverer/aggregator/cleanup/submitter/submission-expiry Lambdas.\n- **Direct-submit:** the seller already produces finalized hourly totals and writes them\n directly into `aggregated_usage`. Deploys only the aggregated-usage table, the submitter, the\n submission-expiry Lambda, and the registration/subscription path. In this mode a record must\n be **final before insert** — any record present in `aggregated_usage` may be submitted on the\n next run, and a second write to the same group returns `DuplicateRecord` (the later quantity is\n not billed).\n\nMode is not a one-way door: a seller can switch later via a stack update that adds or removes\nthe mode-specific resources (the raw usage table is retained on deletion). The deployment-mode\ncondition gates the raw-pipeline resources so no always-present resource ever references a gated\none.\n\n## Scale ceiling and higher-throughput ingestion\n\nSecond precision bounds a group at ≤3,600 rows/hour, and the whole pipeline is sized for that:\nthe aggregator reads at most ~1.8 MB per group and streams the fold; cleanup fans a group into\nat most ~36 SQS messages of ≤100 keys each; the single serial submitter drains a large backlog\nwithin its 5-minute schedule. The design supports high volume as long as ingestion is\n≤ ~1 write/second per `(licenseArn, customerAWSAccountId, dimension)` group — i.e. the seller's\nwriter collapses sub-second events into the per-second row.\n\nFor finer-than-per-second capture or higher sustained per-group rates, front the usage table\nwith a streaming ingestion tier (for example, Amazon Kinesis Data Streams): buffer raw events in\nthe stream and aggregate to per-second (or per-hour) rows in a consumer before writing them to\nthe usage table. The downstream pipeline is unchanged. Setting up that ingestion tier is\ngeneral AWS practice rather than a first-class part of this skill.\n\n## Observability\n\nAlarms cover every failure mode so none is silent. On the main stack (16 alarms): pipeline\nLambda `Errors` (discoverer, aggregator, submitter, submission-expiry — cleanup failures surface\nvia its DLQ); submitter and aggregator `Throttles`; \"did not run\" alarms for the submitter and\nthe discoverer (the discoverer being the sole enqueuer and age-out path); work,\ndeprovision-work, and cleanup DLQ depth; work-queue and deprovision-work-queue oldest-message-age\n(the deprovision threshold well under the ~1-hour window, since a DLQ-depth signal alone fires\ntoo late for that urgent path); and the business-status metrics `UsageAggregationExpired`,\n`UsageSubmissionExpired`, `UsageRecordRejected`, `UsageRecordUnprocessed` (records still\nunprocessed after the submitter's single retry — the submitter does not raise on this, so this\nis the only alarm-able signal for it), `CustomerNotSubscribed`, and\n`BatchMeterUsageException`. The events stack alarms the subscription Lambda and its DLQ, and the\ndeprovision-cleanup Lambda's `Errors` and \"did not run\" (it solely owns deprovisioning\nfinalization and the self-emptying of the sparse index).\n\nBusiness-status metrics are emitted via CloudWatch Embedded Metric Format from the Lambda logs,\nso they need no extra runtime IAM. Every alarm's action wires to an optional seller-supplied\nin-Region SNS topic; handling alarms is the seller's responsibility. Two health dashboards are\ncreated by default (a `CreateDashboard` parameter opts out): a per-product main-stack dashboard\n(per-Lambda Errors/Throttles/Invocations/Duration; queue and DLQ depths and oldest-message-age;\nthe granular EMF status metrics, with `UsageRecordRejected` broken down by reason via a Metrics\nInsights query) and a shared events-stack dashboard (subscription Lambda health and queue/DLQ\ndepth).\n\n## Data retention\n\nBoth tables support seller-configurable TTL:\n\n- **Raw usage table** (`UsageTableTtlDays`, default 365, recommended on): the high-volume table,\n so a TTL controls storage cost. The floor (≥2 days, and greater than `MeteringLockHours` + 24\n hours) is chosen so a row is not deleted before it is metered. The seller's writer sets the\n `ttl` attribute; the template only makes DynamoDB honor it.\n- **Aggregated-usage table** (`AggregatedUsageTableTtlDays`, default 0 = retain): a small billing\n audit trail. When enabled, the submitter sets `ttl` only on finalized `Success` records.\n\nA value of 0 omits the `TimeToLiveSpecification` via a CloudFormation condition.\n\n## Security posture\n\n- **Least privilege.** Each Lambda role grants only the actions it uses. The meter path holds\n `aws-marketplace:BatchMeterUsage` plus scoped DynamoDB `Query`/`UpdateItem` on its tables and\n the `metering_pending` index; the registration Lambda holds `aws-marketplace:ResolveCustomer`\n plus `PutItem`/`UpdateItem`/`Query` on the in-Region customer-profile table (and its GSIs) and\n PII-free `GetItem`/`UpdateItem`/`Query` on the subscribers table; the deprovision-cleanup role\n holds `Query` on the sparse index and `UpdateItem` on the subscribers table. No role holds\n `dynamodb:Scan` or a broad `dynamodb:*`/`aws-marketplace:*`.\n- **Buyer PII stays in-Region** in the customer-profile table, is masked in logs, and is never\n written to the shared us-east-1 subscribers table.\n- **Encryption at rest** on SQS, SNS, and DynamoDB; Lambda and API log groups are explicit\n resources with retention and optional KMS keys (they may contain buyer account IDs and license\n ARNs).\n- **The registration API is intentionally unauthenticated** (AWS Marketplace posts the\n fulfillment token to it), so the template attaches a baseline WAF WebACL by default\n (rate-based plus common-exploit rules; a seller WebACL overrides it), API Gateway throttling\n and access logging are always on, and the registration Lambda persists only an allowlisted,\n length-bounded set of fields.\n\n## Handler layout\n\nShared helpers live in `metering_core.py`, imported by every handler.\n\n| Module | Responsibility |\n|--------|----------------|\n| `metering_core.py` | DynamoDB/SQS clients; `get_pending_groups` and `get_pending_groups_for_license` (GSI discovery); `read_group_rows` (targeted `begins_with`); the `collect_group` fold; `merge_allocations`, `validate_group`, `finalize_allocations`; `submit_records` (retry-once, bisect-isolate); `deprovisioning_licenses` / `deprovisioning_licenses_with_expiry` (sparse-index queries); audit-timestamp and EMF/masking helpers; second-precision time helpers. |\n| `discoverer.py` | `meter` mode — enqueue pending groups for one hour bucket. `ageout` mode — sharded terminal expiry of the raw table. `flush-deprovisioning` mode — expedited per-license flush to the deprovision-work queue, current hour included within 10 minutes of the window close. |\n| `aggregator.py` | Fold a group, validate, conditional-put into aggregated-usage or reject client-side, then enqueue cleanup on a fresh write. |\n| `cleanup.py` | Per-key `UpdateItem` finalization of raw rows (idempotent), from a bounded, backoff-guarded thread pool. |\n| `submitter.py` | Read aggregated-usage pending, order deprovisioning-first then oldest-first, coalesce into `BatchMeterUsage` batches, write back the result. |\n| `expiry.py` | Sweep aggregated-usage for records that can no longer be billed and mark them `SubmissionExpired`. |\n| `deprovision_cleanup.py` (events stack) | Finalize deprovisioning licenses to `inactive` once their flush window has elapsed. |\n| `register.py`, `subscription.py` (events/registration) | Buyer registration (ResolveCustomer + in-Region profile write + PII-free subscriber upsert) and subscription lifecycle handling. |\n\n## EventBridge rule and target layout\n\nSlice identity is a literal constant `Input` per target (the single source of truth), and the\nhour is derived inside the Lambda as `floor(now, hour) - hourOffset`, so targets never race on\nwall-clock time. Age-out shard M owns lookback hours where `hours_back % 7 == M` (complete,\ndisjoint, and idempotent regardless).\n\n| Rule | Targets |\n|------|---------|\n| `awsmp-meter-dispatch-1` (`rate(15 min)`) | hourOffset 1–5 |\n| `awsmp-meter-dispatch-2` (`rate(15 min)`) | hourOffset 6–10 |\n| `awsmp-meter-dispatch-3` (`rate(15 min)`) | hourOffset 11–15 |\n| `awsmp-meter-dispatch-4` (`rate(15 min)`) | hourOffset 16–20 |\n| `awsmp-meter-dispatch-5` (`rate(15 min)`) | hourOffset 21–23 + ageout shard 0,1 |\n| `awsmp-meter-dispatch-6` (`rate(15 min)`) | ageout shard 2–6 |\n| `awsmp-submit-dispatch` (`rate(5 min)`) | submitter + submission-expiry |\n| `awsmp-flush-deprovisioning` (`rate(5 min)`, full pipeline only) | discoverer `{\"mode\":\"flush-deprovisioning\"}` |\n\nThe meter rules run every 15 minutes rather than hourly: meter mode is idempotent and each\nbucket's identity is its constant target offset, so a bucket is simply re-evaluated up to four\ntimes an hour and the conditional put de-duplicates — this shortens the time from a late write\nbecoming eligible to its submission without changing correctness.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":37731,"content_sha256":"fdb6d3c6e2e10dfa2bc1af287a6779375d02aa2bff7f1ead30e9b8e41be8d65b"},{"filename":"references/seller-reports.md","content":"# Seller Reports — Reconciliation & the SDDS Automation Boundary\n\nThis file covers only what the metering skill needs that the public AWS docs do not spell out\nin this context: **how Seller Reports relate to the CloudTrail query path** (reconciliation)\nand **why programmatic Seller-Reports access is not straightforward to automate** (the SDDS\nboundary). The report catalogs (column dictionaries, IAM, dashboards) are AWS-documented and\nchange over time — use the official docs rather than a copy that can drift:\n\n- Reports, data feeds & dashboards (overview): https://docs.aws.amazon.com/marketplace/latest/userguide/reports-and-data-feed.html\n- Seller Delivery Data Service (SDDS): https://docs.aws.amazon.com/marketplace/latest/userguide/data-feed.html\n- AMMP reports console: https://aws.amazon.com/marketplace/management/reports/\n\nFor the exact listing-fee percentage, billed columns, and IAM actions, read the seller's own\nBilled Revenue report and the docs above — do NOT hardcode a fee percentage or a column list\nin the skill.\n\n## Two channels (what each is for)\n\n- **AMMP Insights \"Billed Revenue\" dashboard** — interactive dashboards in the AWS Marketplace\n Management Portal. Billed revenue, per-customer/per-dimension usage, disbursements;\n billing-of-record. **Needs NO prior setup and covers ANY range, including a full year** —\n it requires only `aws-marketplace:GetSellerDashboard`; the default view is the **last 6\n months**, so widen the invoice-date filter for a year or more. It is **CONSOLE-ONLY**\n (QuickSight) with manual CSV export — there is **no on-demand billed-revenue query API**.\n- **Seller Delivery Data Service (SDDS)** — the programmatic path: AWS **pushes** CSV data\n feeds to a seller-owned S3 bucket (see the boundary below), which the seller queries with\n their own Athena/ETL. Requires `aws-marketplace:GetSellerDashboard`. **Push-only and delivers\n only FROM ENROLLMENT FORWARD — it does NOT backfill**, so a historical year is available\n programmatically only if SDDS was already enrolled during that period; otherwise use the\n console dashboard above for the historical range. **Do NOT use the deprecated\n `GenerateDataSet`.**\n\n> **Raw SUBMISSION history (incl. rejected/duplicate) beyond 90 days has no billed source** —\n> Seller Reports/SDDS are billed-only, and CloudTrail Event History retains only 90 days. To\n> retain raw submissions long-term, set up a self-managed CloudTrail **trail → S3** on\n> `metering-marketplace.amazonaws.com` (a plain trail, NOT CloudTrail Lake); like SDDS it\n> captures only FROM SETUP FORWARD and does not backfill. Because the bucket stores raw\n> metering submission data, **encrypt it at rest with SSE-KMS** (set `KMSKeyId` on the trail,\n> a customer-managed key) and **enforce encryption in transit** via a bucket policy denying\n> `aws:SecureTransport=false`; block public access. See `references/query-patterns.md`.\n\nMetered usage is billed on the **2nd/3rd of the following month** (e.g. November usage lands on\nthe December invoice), and dashboard revenue is **estimated** until the month closes.\n\n## Billed Revenue report — key CSV columns\n\nThe exact schema is AWS-documented and can change — read the seller's own export / the docs\nabove for the authoritative list — but the Billed Revenue / usage CSV export includes both\n**financial** and **identity** columns you can name when a seller asks \"what columns are in the\nreport?\":\n\n- **Financial:** `Gross revenue` (billed amount before fees; a Private-Offer discount shows here\n as a lower value — there is no separate discount column), `Listing fee` (AWS Marketplace fee),\n `Seller net revenue` (what the seller nets after the listing fee), plus the currency/period\n columns.\n- **Identity / attribution:** `Subscriber AWS account ID` (the buyer account), `Agreement ID`\n (the agreement, maps to the `agreementId` on the subscriber row), and `Product ID` (the\n product), plus the usage `Dimension`.\n\nUse these to join billed rows back to the submitted CloudTrail records (by account + agreement/\nproduct + dimension + period) during reconciliation. Do NOT hardcode a listing-fee percentage —\nread it from the report.\n\n## Reconciliation: CloudTrail (submissions) vs Seller Reports (billed)\n\nThe two views answer different questions — this is the model the query path relies on:\n\n| Question | Source |\n|----------|--------|\n| Did my `BatchMeterUsage` call succeed / what did the API return? | **CloudTrail** (≤90 days) — the skill's query path |\n| What was actually **billed** / disbursed? | **Seller Reports** (billed amounts, full history) |\n| Why don't the totals match? | See below |\n\n**Totals don't match?** Expected. **CloudTrail shows ALL submissions** — including\n`DuplicateRecord` (first-write-wins; a differing-quantity resubmit is NOT billed — an\nunder-billing risk, not simply \"benign\") and `CustomerNotSubscribed` (rejected). **Seller\nReports show only BILLED amounts.** The difference is the rejected/duplicate delta. Other common\nmismatches: a `$0`-revenue dimension usually means a **dimension-key mismatch** (case-sensitive\n— accepted by the API but not billed); revenue appearing \"late\" is the 2nd/3rd-of-month billing\ncadence; and subscriber ≠ payer under consolidated billing.\n\nThere is **no separate \"discount\" field**: Private Offer discounts show up as a lower\n`Gross revenue`; a native **AWS Marketplace SaaS free trial** shows the metered usage at `$0`\nestimated revenue until it converts (if the seller's own pricing meters only above a free\nallowance, there is simply no usage to show below it); channel-partner\n(CPPO) margin is `Gross revenue − Wholesale cost`.\n\n## The SDDS automation boundary (why the skill recommends, but does not run, this path)\n\nSDDS is **push-based, not an on-demand query API**: AWS Marketplace delivers structured **CSV**\ndata feeds **daily (~midnight UTC)** into a seller-owned, KMS-encrypted S3 bucket, in a\nbi-temporal structure. **There is no API/CLI to trigger a delivery or query the feeds on\ndemand.** Setup requires a **one-time manual step that cannot be automated**:\n\n1. Deploy the AWS-provided data-feed resources (KMS key + encrypted S3 bucket/policy, optional\n SNS topic) — automatable.\n2. **Manual, mandatory:** in AMMP → **\"Set up customer data storage\"** → submit the bucket/KMS\n (and optional SNS) ARNs. This subscribes the account; it cannot be done via API/CLI.\n3. The seller then runs **their own query/ETL** (e.g. Athena over the delivered S3 CSVs).\n\nConsequently the metering skill **recommends** this path for billed totals and data older than\n90 days but **does not perform** it: the skill cannot complete the manual enrollment for the\nseller, and querying the delivered feeds is the seller's own responsibility. Do NOT ship or\nreference the deprecated `marketplacecommerceanalytics` `GenerateDataSet` (legacy CSV export)\nAPI — SDDS is the only current, supported programmatic mechanism.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":6972,"content_sha256":"e3484f4144c8c31570ca63f324b73cfe84fa35d35ae8753da5b34311ee82bdc6"},{"filename":"references/setup-path.md","content":"# Setup Path — Deploy New Metering Integration\n\n## Product Type Gate\n\n> **IMPORTANT:** Before proceeding, verify the product is SaaS with usage-based pricing (ExternallyMetered dimensions). Query the Catalog API after getting credentials. If the product is AMI, Container, or SaaS Contract-only, respond that the product type is out of scope and redirect to the [public AWS documentation](https://docs.aws.amazon.com/marketplace/).\n\n## Seller Questionnaire\n\nAsk ALL five questions before generating any code:\n\n**Q1: Product code and dimensions?**\n\n- \"What is your Marketplace product code (or entity ID) and which usage dimensions do you want to meter?\"\n- They MUST provide the product code (like `4ml54db8vrmjuykaw1psroool`) and at least one dimension key.\n\n**Q2: Are you the ISV/seller or building for one?**\n\n- Confirms they have access to the seller account and AWS Marketplace Management Portal.\n\n**Q3: Multi-region or single region?**\n\n- Events stack is always in us-east-1 (marketplace lifecycle events emit only there). Deploy a metering (main) stack in **each** region where the seller runs and meters their SaaS, each with its own regional usage table.\n- **`BatchMeterUsage` is a regional API** — call it in-region from where the usage occurred (for SaaS on another cloud or on-premise, use the nearest AWS region). There is no benefit to routing all usage through one region, and doing so works against data-residency (e.g. EU usage should stay in `eu-west-1`).\n- **Metering records are per-region.** De-duplication is per **CustomerAWSAccountId + LicenseArn + dimension + hour** (SaaS CA products), scoped to the region the record was submitted in, first-write-wins within that region. A buyer whose usage genuinely spans regions is metered in each region where the usage actually occurred — the same customer/dimension/hour submitted in different regions is expected and correct. Do NOT force all of a customer's usage into a single region and do NOT funnel every customer's usage into one global table.\n- Supported regions: see [BatchMeterUsage region support](https://docs.aws.amazon.com/marketplace/latest/developerguide/metering-regions.html#batchmeterusage-region-support).\n\n**Q4: API stage name (mandatory)?**\n\n- \"What API Gateway stage name should the registration endpoint use — for example `v1`, `live`, or `prod`? This is your choice and there is no default.\"\n- The `StageName` template parameter has **no default** and an `AllowedPattern` that rejects an empty value. The seller MUST supply a non-empty value; there is no silent default and no fallback to `prod`. A direct `sam deploy` fails fast at the template level if it is missing/empty, and `deploy.sh` performs the same check (pass the seller's choice via `STAGE_NAME`; the fulfillment URL tracks it).\n\n**Q5: Does a metering stack already exist for this product?**\n\n- If yes (or the skill otherwise determines one exists), treat this as a modify/extend case: gather context on the seller's existing stack and inspect it before changing anything. Do NOT assume it follows this skill's `awsmp-*` naming — see `references/existing-sellers.md`.\n\n> **CCP is out of scope (handled at the Product Type Gate above).** If the product is Contract with Consumption Pricing, contract-only, AMI, or Container, respond that it is out of scope and redirect to the [public AWS documentation](https://docs.aws.amazon.com/marketplace/latest/userguide/saas-integrate-contract.html); do NOT provide CCP implementation steps, code, or API sequences.\n>\n> **Concurrent Agreements is the default.** All new products use `CustomerAWSAccountId` + `LicenseArn`. Do not ask the seller — this is the default for all new products.\n\n## Product Portfolio: Stack Naming Convention\n\n| Component | Pattern | Example |\n|-----------|---------|---------|\n| Events stack | `awsmp-events-stack` | Shared across all products, us-east-1 |\n| Main stack | `\u003cprefix>-metering` | `awsmp-prod-abc123-metering` |\n| Prefix | `awsmp-\u003cenv>-\u003cproduct-shortcode>` | `awsmp-prod-u32tk6e2xza22` |\n\n## Supported Features\n\n| Feature | Status |\n|---------|--------|\n| SaaS Usage (PAYG) | ✅ Supported |\n| Concurrent Agreements | ✅ Supported |\n| Contract with Consumption Pricing (CCP) | ❌ Out of scope — redirect to [public AWS docs](https://docs.aws.amazon.com/marketplace/latest/userguide/saas-integrate-contract.html) |\n| [Vendor Metered Tagging (VMT)](https://aws.amazon.com/blogs/aws-cloud-financial-management/launch-aws-marketplace-vendor-metered-tagging/) | ✅ Supported |\n| Multi-region SaaS runtime | ✅ Supported — `BatchMeterUsage` is regional; deploy a main stack per metering region and meter in-region (nearest AWS region for on-prem/other-cloud). Metering records are per-region; the same customer/dimension/hour can be metered in each region where that customer's usage occurred. Do NOT force all of a customer's usage into one region. |\n| Multi-product portfolio | ✅ Supported |\n| AMI/Container metering | ❌ Out of scope — redirect to [public AWS docs](https://docs.aws.amazon.com/marketplace/) |\n| SaaS Contract-only (GetEntitlements) | ❌ Out of scope — redirect to [public AWS docs](https://docs.aws.amazon.com/marketplace/) |\n\n## Prerequisites\n\n- AWS account with Marketplace seller registration\n- Product listed in AWS Marketplace (product code assigned)\n- At least one usage dimension configured on the product\n- Product must be **SaaS with ExternallyMetered dimensions** (verified via Catalog API)\n- **One-time IAM prerequisite (first-time sellers):** the **`awsmp-metering-boundary`** permissions boundary and the **`MarketplaceMeteringDeployer`** role must be created in the account BEFORE the first deploy — the skill does not create them for itself and the deploy fails closed without them. See the one-time bootstrap in `references/iam-credentials.md` → \"Prerequisite setup (do this FIRST…)\".\n\n## AWS Credentials (ephemeral-first — configure before deploying)\n\n> **First time in this account? Do the one-time IAM prerequisite first.** Before configuring\n> credentials, make sure the **`awsmp-metering-boundary`** permissions boundary (created FIRST)\n> and the **`MarketplaceMeteringDeployer`** role (conditioned trust policy) exist — the copy-paste\n> bootstrap is in `references/iam-credentials.md` → \"Prerequisite setup (do this FIRST…)\". The\n> boundary MUST exist before the role/first deploy (the deployer's `iam:CreateRole` is gated on\n> it), and its name MUST match the `PermissionsBoundaryName` parameter. Verify with\n> `aws iam get-policy …/awsmp-metering-boundary` and `aws iam get-role MarketplaceMeteringDeployer`.\n\nHave the seller configure **ephemeral** credentials in their own environment before you\ndeploy or run tests — do NOT ask them to hand over long-lived IAM user access keys, and do\nNOT ask for credentials outright as a first step. Preferred order:\n\n1. **IAM Identity Center / SSO** — `aws configure sso && aws sso login`\n2. **STS assume-role** — assume a dedicated deployer role (session includes `AWS_SESSION_TOKEN`)\n3. **Last resort:** short-lived STS session env vars (with `AWS_SESSION_TOKEN`) — never long-lived user keys\n\nThen verify with `aws sts get-caller-identity` and confirm the correct seller account before\nproceeding. The same ephemeral credentials are used for Catalog API validation, deployment,\nand integration testing.\n\n> For the ephemeral-first setup commands and the full least-privilege IAM deployer policy\n> (conditioned trust policy + permissions-boundary'd `iam:CreateRole`), load\n> `references/iam-credentials.md`.\n\n## Validate Product Code and Dimensions via Catalog API\n\nAfter credentials are verified, run this command. Do NOT skip — incorrect values cause runtime failures.\n\n```bash\naws marketplace-catalog describe-entity \\\n --catalog AWSMarketplace \\\n --entity-id \u003cPRODUCT_ID> \\\n --region us-east-1 \\\n --query 'Details' --output text | python3 -c \"\nimport sys, json\ndetails = json.loads(sys.stdin.read())\nprint('=== Dimensions (use these Key values for metering) ===')\nfor d in details.get('Dimensions', []):\n print(f\\\" {d['Key']} - {d['Name']} ({d['Unit']}) - Types: {d.get('Types', [])}\\\")\n\"\n```\n\n> **Important:** The product ID (`prod-xxx`) is NOT the product code. Always verify via the Catalog API or AMMP.\n\n## Tool Requirements\n\n- A recent AWS SAM CLI (`sam --version`) and AWS CLI v2 (`aws --version`). Verify the installed versions work; consult the SAM CLI / AWS CLI release notes if a command is unsupported.\n\n## Deployment Steps\n\n> For the full 6-step deployment walkthrough, load `references/deployment-steps.md`.\n\nSummary:\n\n1. Generate SAM template + Lambda code\n2. Deploy Events Stack (us-east-1) — shared EventBridge rules\n3. Deploy Main Stack (seller's region) — Lambda + DynamoDB + API Gateway\n4. Configure Fulfillment URL in AMMP\n5. Test End-to-End (subscribe → resolve → write conforming usage row → discoverer → aggregator → submitter → verify `Aggregated` + `Success`)\n6. Validate via CloudTrail logs\n\n## ⚠️ Usage Ingestion — Seller's Responsibility\n\nThe deployed stacks provide the **metering pipeline** (discoverer → aggregator → cleanup → submitter; a shared subscription path) that reads usage rows from the usage DynamoDB table and submits them to `BatchMeterUsage`. However, **writing usage rows into the table is entirely the seller's responsibility.**\n\nThe seller must build:\n\n- **Usage capture** — instrument their application to record per-customer, per-dimension usage events\n- **Cross-account aggregation** (if multi-account runtime) — collect usage from all runtime accounts into the central metering account\n- **Write pipeline** — durably write usage rows into the usage DynamoDB table, in the exact schema below, before the hour is aggregated\n\nThe pipeline meters whatever conforms to the schema; it does not distinguish billable from non-billable usage and applies no business filtering. The seller must ensure only metered-eligible usage lands in the table.\n\n**Recommended pattern:** Durable buffer (SQS/Kinesis) → per-second/hour idempotent aggregator (Lambda/Step Functions) → usage DynamoDB table.\n\n### Usage-row writer contract (what every row MUST look like)\n\nThe pipeline reads rows on trust for what it cannot re-derive, and reason-code-rejects (raw-row `meteringStatus = RejectedClientSide`, `UsageRecordRejected` metric) what it can check. A row MUST have:\n\n| Attribute | Requirement | If wrong |\n|---|---|---|\n| `licenseArn` (PK) | The buyer's LicenseArn from the `License Updated` event (or, legacy pre-CA, the ProductCode fallback). | Group keyed wrong / not billed for the right buyer. |\n| `customerAWSAccountId_dimension_timestamp` (SK) | EXACTLY `{customerAWSAccountId}#{dimension}#{YYYY-MM-DDTHH:MM:SS}` — **whole-second** precision in **UTC**, NOT millisecond/`.000Z` and NOT local time. The account + dimension segments MUST equal the row's own `customerAWSAccountId` / `dimension` attributes. | Rejected client-side with one code per condition (in order): `MalformedSortKey` (empty / fewer than 3 `#`-segments) → `SortKeyMismatch` (account or dimension segment ≠ the row's own attribute) → `MalformedTimestamp` (timestamp segment not exact whole-second, incl. millisecond/fractional or hour-truncated). A local-time timestamp instead buckets in the wrong UTC hour (silently mis-metered). |\n| `customerAWSAccountId`, `dimension`, `timestamp` | Stored ALSO as separate top-level attributes (the pipeline reads these). `dimension` present and non-empty; `timestamp` in UTC. | `MissingDimension` reject; or discovered under the wrong group. |\n| `meteringPending` | Set to the row's hour bucket `YYYY-MM-DDTHH` in **UTC** — this is the GSI HASH that makes the row discoverable, and the pipeline queries it by UTC bucket (`now` is UTC). It MUST equal the UTC hour of the row's own timestamp. The pipeline REMOVES it when the row is finalized; the writer sets it only on a new pending row. | Wrong value (incl. a LOCAL-time bucket) ⇒ row is discovered under the wrong hour and **silently never metered**, or malformed ⇒ never discovered at all. **The pipeline cannot catch this — it is verified only by the hands-on test.** |\n| `quantity` | Non-negative INTEGER (no negative, fractional, or non-numeric). | `NegativeQuantity` / `NonIntegerQuantity` / `NonNumericQuantity` reject. |\n| `usageAllocations` (optional, VMT) | If present, exact BatchMeterUsage `UsageAllocation` shape; all-or-nothing across a group's non-zero rows; ≤2500 tag-sets; ≤5 tags each; allocations sum to the row quantity. | `MalformedAllocations` / `TooManyTags` / `TooManyAllocations` / `AllocationSumMismatch` / `MixedAllocatedAndUnallocated` reject. |\n| `ttl` (optional) | Epoch-seconds; only honored if the raw-table TTL is enabled. MUST be far enough out that a row is never TTL-deleted before it is metered (the template validates the floor). | Premature deletion of un-metered usage. |\n| `createdAt`, `updatedAt` (recommended) | ISO-8601 UTC audit timestamps — `createdAt` set once when the writer first inserts the row, `updatedAt` refreshed on any rewrite. Audit/reference metadata only (not keyed, not billing-relevant). The pipeline stamps `updatedAt` when it finalizes a row (and `createdAt` if absent); the seller SHOULD stamp them at write time for its own rows. | No metering impact; only lost audit trail if omitted. |\n\n> **The `meteringPending` accuracy point is the one the pipeline cannot self-correct.** If the writer stamps a bucket that disagrees with the row's timestamp hour, the row is discovered under a bucket whose read-prefix its sort key does not match, so its quantity is silently omitted and its marker is never cleared. There is no client-side reject for it (the row is effectively invisible to the group that would validate it). Getting `meteringPending == floor(timestamp, hour)` right is a hard writer obligation — the hands-on test below is how the seller confirms it.\n\n### What the seller owns once the stacks are in place\n\nAfter the two stacks deploy successfully, the skill SHALL make these ongoing seller responsibilities explicit:\n\n1. **Write usage rows** conforming to the contract above (the only thing that makes metering happen).\n2. **Wire the alarms** — subscribe an SNS topic / notification to the created alarms (`AlertsTopicArn`); the stack raises them but acting on them is the seller's job (`deployment-steps.md`).\n3. **Watch the health dashboards** — especially `UsageRecordRejected` (writer-contract violations, sliced by `Reason`), `BatchMeterUsageException` / `CustomerNotSubscribed` (terminal, server-side), and `UsageAggregationExpired` (raw usage aged out before aggregation) / `UsageSubmissionExpired` (aggregated but not submitted in time).\n4. **Manage dimensions in the catalog** — add/rename pricing dimensions in AMMP; no redeploy is needed (dimension validity is server-authoritative).\n5. **Set the Fulfillment URL** in AMMP to the **branded registration page** URL (the CloudFront/custom-domain front-end that captures buyer/KYB fields and calls `RegistrationUrl`); the raw `RegistrationUrl` is only a bare-bones smoke-test fallback. See `references/registration-page.md`. (A one-time manual seller step.)\n6. **Own the deployed source** — the generated template + handlers live in the seller's workspace; re-deploy from there and customize the marked `TODO:` sections.\n\nThe skill SHALL then **walk the seller through the hands-on end-to-end test in `references/deployment-steps.md` Step 5** — subscribe → verify registration/EventBridge → write a conforming test usage row (correct second-precision sort key + matching `meteringPending`) → trigger discoverer/aggregator/submitter → verify the raw row finalizes as `Aggregated` and the aggregated record reaches `Success`. This exercise is how each writer-contract expectation above is demonstrated and confirmed to be met, and how a mis-stamped `meteringPending` (otherwise silent) is surfaced.\n\n## Additional References\n\n- **Concurrent Agreements details:** Load `references/concurrent-agreements.md`\n- **Architecture diagram + data model:** Load `references/architecture.md`\n- **Troubleshooting errors:** Load `references/troubleshooting.md`\n- **Existing sellers (modify/extend):** Load `references/existing-sellers.md`\n\n## Common Mistakes\n\n1. Using `CustomerIdentifier` instead of `CustomerAWSAccountId` in UsageRecords\n2. Mixing `ProductCode` and `LicenseArn` for the same customer/hour\n3. Using SNS instead of EventBridge (SNS lacks `CustomerAWSAccountId`)\n4. Deploying EventBridge rule in seller's region instead of us-east-1\n5. Dimension key not defined in the catalog — `BatchMeterUsage` fails the call with a request-level `InvalidUsageDimensionException` (server-authoritative; the submitter isolates the record → `RejectedClientSide` reason `InvalidUsageDimensionException`; surfaced on the `BatchMeterUsageException` metric/alarm, not billed). Fix the key or add the dimension in AMMP; no redeploy needed.\n6. Writing the sort-key timestamp at millisecond precision (`.000Z`) instead of whole-second `YYYY-MM-DDTHH:MM:SS` — rejected as `MalformedTimestamp`.\n7. Stamping `meteringPending` with an hour that doesn't match the row's timestamp hour — the row is discovered under the wrong bucket and **silently never metered** (the one failure the pipeline can't catch; verify via the Step 5 test).\n8. Activating metering on `Purchase Agreement Created` — that event carries no licenseArn and is not consumed by the metering path. Metering follows `License Updated` (access granted) and the usage table's `metering_pending` GSI.\n\n## Security\n\n- Lambda needs ONLY `aws-marketplace:BatchMeterUsage` + DynamoDB read/write\n- EventBridge rule scoped to `source: [\"aws.agreement-marketplace\"]`\n- Enable CloudTrail for audit\n- Separate accounts for prod vs test\n\n## Unified Subscribers Table (exact schema)\n\nUse ONE unified DynamoDB table for SUBSCRIPTION/AGREEMENT STATE (do NOT split subscription state across separate \"customer-profiles\" + \"subscriptions\" tables). Buyer PII / registration-form data is a SEPARATE concern and lives in its own per-Region `customer-profile` table (below) — that separation is intentional (keeps the subscribers table PII-free), not the old split-subscription-state anti-pattern.\n\n- **Concurrent Agreements (default):** `licenseArn` = PARTITION key (HASH), `customerAWSAccountId` = SORT key (RANGE). LicenseArn MUST be the partition key. One buyer with N concurrent agreements = N rows (same customerAWSAccountId, different licenseArn), tracked independently.\n- **Legacy (pre-CA):** `customerAWSAccountId` = partition key (understand the seller's actual schema first; do not impose the CA layout).\n- **Attributes on a new CA record (PII-FREE):** `productCode`, `agreementId`, `agreementStatus` (active/inactive — agreement lifecycle), `subscriptionStatus` (active/deprovisioning/inactive — license lifecycle; no `deprovisioned` value), `registeredRegions` (DynamoDB String Set of AWS Region names, added idempotently via `ADD`, reference-only). It does NOT carry `customerIdentifier` (deprecated, legacy-only) and does NOT carry buyer PII / registration-form data. Buyer PII lives in the per-Region `customer-profile` table (`awsmp-\u003cproductCode>-customer-profile`, PK `licenseArn` + SK `customerAWSAccountId`, seller-requested GSIs), written in-region by the register Lambda — so the subscribers table stays PII-free and safe in us-east-1 for opt-in-Region products.\n- **Two GSIs** so lookups never need a `Scan`: `customerAWSAccountId-index` (register lookup / all agreements for one buyer) and `agreementId-index` (subscription lookup when an event omits `license.arn`).\n\n**Usage table:** keyed by `licenseArn` (PARTITION key) + `customerAWSAccountId#dimension#timestamp` (SORT key) — the sort-key value is the `#`-delimited composite `{customerAWSAccountId}#{dimension}#{timestamp}` at **second precision** (`YYYY-MM-DDTHH:MM:SS`), bounding a group at ≤3600 rows/hour. Each item ALSO stores `customerAWSAccountId`, `dimension`, and `timestamp` as top-level attributes so the pipeline reads them without parsing the sort key. It has a sparse `metering_pending` GSI (HASH = `meteringPending` hour-bucket, RANGE = `licenseArn`): the usage writer sets `meteringPending` when it writes a row, and the pipeline clears it after the row's aggregation group reaches a terminal result.\n\n## Metering pipeline flow (recite when asked how metering works)\n\nMetering is a decoupled, SQS-connected pipeline driven by the usage table's `metering_pending` GSI, NOT by registration state — not every seller uses ResolveCustomer (some obtain the LicenseArn via EventBridge or SDDS and never register), so metering must not depend on a registered subscriber. The stages:\n\n1. **Discoverer** (hourly, `metering_pending` GSI): queries the GSI for a completed hour bucket and enqueues ONE work message per `(licenseArn, customerAWSAccountId, dimension, hourBucket)` group onto the work SQS queue. It only meters completed hours in the `now-23h … now-1h` window (never the in-progress hour) and only once an hour has been closed for `MeteringLockHours` (default 1). It does NOT read group rows, aggregate, submit, or write back. The same discoverer, on its age-out targets, terminally expires raw rows older than 24h that were never aggregated (REMOVE `meteringPending`, `meteringStatus=AggregationExpired`, `UsageAggregationExpired` metric).\n2. **Aggregator** (SQS-triggered, one message = one group): reads the group's rows via the targeted `begins_with` prefix (streamed fold), takes `customerAWSAccountId`/`dimension` from the rows (no subscriber lookup), aggregates all second-precision rows into ONE quantity, and MERGES seller-provided `usageAllocations`. It runs the structural/format client-side validations (dimension present; second-precision sort key with matching segments; non-negative integer quantity; timestamp window; identifier shape; allocation invariants) — dimension NAME validity is left to `BatchMeterUsage`. A valid group is written as ONE record via conditional `PutItem` into the `awsmp-aggregated-usage` table (idempotency commit point); a rejected group is finalized `RejectedClientSide` with a reason code + `UsageRecordRejected` metric.\n3. **Cleanup** (SQS-triggered): after the aggregated record is durably written, clears `meteringPending` on the raw rows and stamps `meteringStatus=Aggregated`. **The raw usage table NEVER carries the submission outcome** — its `meteringStatus` domain is exactly `Aggregated` / `RejectedClientSide` / `AggregationExpired`.\n4. **Submitter** (EventBridge `rate(5 minutes)`, `reserved=1`): reads pending records from `awsmp-aggregated-usage` over the `now-23h … now-1h` window OLDEST-first, coalesces ≤25 UsageRecords per `BatchMeterUsage` call, retries `UnprocessedRecords` once, and writes the returned `MeteringRecordId` + per-record `Status` (`Success`/`DuplicateRecord`/`CustomerNotSubscribed`/…) back **onto the `awsmp-aggregated-usage` record only** (REMOVE its `meteringPending`). Records still unprocessed after the retry keep their marker and the invocation FAILS so the `Errors` alarm fires. It finalizes `deprovisioning` subscribers after a successful final flush.\n5. **Expiry** (separate EventBridge `rate(5 minutes)` target, `reserved=1`): terminally expires a pending `awsmp-aggregated-usage` record that can no longer be metered — its hour is >24h in the past, OR it is a previous-month record and the month-end grace has closed (on/after 06:00 UTC on the 1st) — REMOVE `meteringPending`, `meteringStatus=SubmissionExpired`, `UsageSubmissionExpired` metric.\n\n**Zero-quantity groups:** while its hour is still inside the window, a group summing to 0 is left pending (not submitted) so the seller can still write usage for that hour — submitting a 0 early would lock the hour at 0 via first-write-wins dedup. At the oldest edge of the window it IS submitted as `Quantity: 0` to close the hour out.\n\n**Throughput / concurrency:** the pipeline uses FIXED concurrency (there is no single meter Lambda and no `MeterReservedConcurrency` parameter): discoverer `ReservedConcurrentExecutions=30`, aggregator SQS-ESM `MaximumConcurrency: 50` with `ReportBatchItemFailures`, cleanup SQS-ESM `MaximumConcurrency` (bounded per-partition writer), submitter and expiry `reserved=1`. SQS absorbs bursts, so scale comes from queue depth draining rather than raising Lambda concurrency. If a seller needs higher sustained metering TPS than the `reserved=1` serial submitter + `BatchMeterUsage` rate limit allow, advise them to contact AWS Marketplace Seller Operations rather than raising these values unsafely.\n\n## Deployment modes — the aggregation pipeline is OPTIONAL\n\nThe full pipeline above (discoverer → aggregator → cleanup → submitter → submission-expiry) is the default and is for sellers who write RAW per-second usage rows. A seller who **already produces finalized hourly aggregated records upstream** can instead choose **direct-submit** mode (`DEPLOYMENT_MODE=direct-submit`): the skill deploys ONLY the `aggregated_usage` table + submitter + submission-expiry + the register/subscription path — no raw usage table, no work/cleanup queues, and no discoverer/aggregator/cleanup. The seller writes finalized records straight into `awsmp-aggregated-usage`:\n\n| Attribute | Requirement |\n|---|---|\n| `licenseArn` (PK) | buyer LicenseArn (or legacy ProductCode fallback) |\n| `account_dimension_hour` (SK) | `{customerAWSAccountId}#{dimension}#{YYYY-MM-DDTHH}` (hour precision, **UTC**) |\n| `customerAWSAccountId`, `dimension`, `hourBucket`, `quantity` | top-level attributes (`hourBucket` in UTC) |\n| `meteringPending` | the hour bucket `YYYY-MM-DDTHH` in **UTC** (makes the record discoverable by the submitter, which queries by UTC bucket) |\n\n> **Finalize before insert (critical).** ANY record present in `aggregated_usage` MAY be picked up and submitted on the next 5-minute submitter run. Write a record ONLY when it is FINAL for its `(licenseArn, customerAWSAccountId, dimension, hour)` — a second write for an already-submitted group returns `DuplicateRecord` (first-write-wins) and the later quantity is **not billed** (under-billing risk). The seller owns the upstream aggregation/idempotency in this mode; the skill provides no raw-row aggregation.\n\nThe alarms and health dashboard are created to match the deployed components — direct-submit mode omits the aggregation-stage alarms/widgets (aggregator/cleanup/discoverer, work/cleanup DLQ, `UsageAggregationExpired`, `UsageRecordRejected`) and shows a submitter/expiry-focused dashboard.\n\n**Mode is not a one-way door.** A seller can start in either mode and switch later (change `DEPLOYMENT_MODE` and re-deploy), and the skill can layer on additional implementation as needs evolve. Switching an actively-metering live stack is a stack UPDATE that adds/removes the mode-specific resources: the raw `usage` table is `Retain` (its data survives), but moving to direct-submit REMOVES the work/cleanup queues + discoverer/aggregator/cleanup — confirm the blast radius with the seller before flipping the mode on a live stack.\n\n## Vendor Metered Tagging (VMT)\n\nVMT lets sellers break usage down by seller-defined tags. **The allocation breakdown is the seller's responsibility, provided on each usage row — there is NO enable flag.** A usage row participates in VMT simply by carrying a `usageAllocations` attribute (a list of `{AllocatedUsageQuantity, Tags:[{Key,Value}]}` objects in the exact BatchMeterUsage `UsageAllocation` shape). The aggregator MERGES the per-row allocations of a group into the hour's single UsageRecord (summing identical tag sets); it never decides or derives the tag split.\n\nRules the usage-writer must follow (the skill explains these at initial integration):\n\n- Each row's `AllocatedUsageQuantity` values SHALL sum to that row's own `quantity`.\n- Max **5 tags** per allocation; max **2500** merged allocations per UsageRecord (a group with more distinct tag sets is rejected as `TooManyAllocations`).\n- **All-or-nothing per group:** for a given `(licenseArn, customerAWSAccountId, dimension, hour)`, either every non-zero row carries `usageAllocations` or none does. Mixing tagged and untagged non-zero rows is rejected client-side (`MixedAllocatedAndUnallocated`). Zero-quantity rows are ignored for this check. Different groups in the same hour/batch may independently have or omit allocations.\n- The merged allocation sum must equal the record `Quantity` (else `AllocationSumMismatch`) — this is automatic if each row's allocations sum to its quantity.\n- If a group has no tagged rows, `UsageAllocations` is omitted.\n\nAsk the seller for their real tag keys/values — the packaged code carries no illustrative tag and no `VmtEnabled`/`VmtTagAttribute` parameter.\n\n## Usage-writer quantity contract\n\nUsage quantities MUST be **non-negative integers**. The aggregator rejects a negative (`NegativeQuantity`), fractional/decimal (`NonIntegerQuantity`), or non-numeric (`NonNumericQuantity`) quantity client-side — it never truncates (e.g. `2.5`→`2`) or clamps a negative to `0`. Aggregate/round in your writer before persisting. A `0` quantity is valid (not a rejection).\n\n## ResolveCustomer token behavior\n\n- Registration tokens are short-lived (~4 hours) and **reusable until they expire** (NOT one-time-use — re-resolving the same token before expiry returns the same result).\n- `ExpiredTokenException` → the buyer re-does \"Set up your account\" in Marketplace for a fresh token (a full resubscribe is NOT required).\n- After a successful resolve, persist `CustomerAWSAccountId` + `LicenseArn` + `ProductCode` and append the invocation Region to `registeredRegions` (idempotent). Do NOT persist `CustomerIdentifier` for a new integration (deprecated, legacy-only).\n\n## TimestampOutOfBounds\n\n- BatchMeterUsage accepts timestamps within the last **24 hours**; older records → `TimestampOutOfBoundsException`, and one bad timestamp rejects the ENTIRE batch.\n- The pipeline's hourly `now-23h … now-1h` catch-up window recovers a missed/failed run within 24 hours (the discoverer re-enqueues pending hours; the submitter re-drains `aggregated_usage`). A 6-hour month-boundary grace period applies (previous-month records accepted until 06:00 UTC on the 1st).\n\n## Cancellation event model\n\n- `License Updated` → `agreementStatus = active` and `subscriptionStatus = active`.\n- `License Deprovisioned` → `subscriptionStatus = deprovisioning` (+ a `deprovisioningExpiry`); OPENS the ~1-hour flush window; the pipeline flushes any remaining aggregated usage, and the events-stack deprovision-cleanup Lambda (`rate(15m)`) sets `subscriptionStatus = inactive` once `deprovisioningExpiry` passes (time-based, not the submitter).\n- `Purchase Agreement Ended` → `agreementStatus = inactive` ONLY (does NOT stop metering or flush).\n- `Purchase Agreement Amended` → update agreement metadata (stays active).\n- `Purchase Agreement Created` → not consumed by metering; the seller handles it where needed.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":31135,"content_sha256":"0d1f663ff3486154be6a97b728cf6bc6c922b0f1341fed46dd1c515412e8d15b"},{"filename":"references/test-plan.md","content":"# Hands-on test plan (verify the stack, don't blindly trust it)\n\nWalk the seller through these test cases so they SEE each resource the skill created behave\ncorrectly. The skill runs the CLI/verification steps; steps marked **[seller/browser]** are\nmanual. State each case's **expected observable outcome** so the seller reviews the evidence\n(a table item's status, a metric, a log line), not just a green check.\n\nThe core happy-path commands are in `deployment-steps.md` Step 5; this plan adds the manual\nsetup, the full case list, and how to handle async waits.\n\n## 0. Manual prerequisites (seller / browser) — a seller↔skill handshake\n\nThese four steps are performed by the SELLER in the browser; the skill CANNOT do them (no\ncatalog-write / no browser). Run them as a handshake: the skill explains the step and pauses,\nthe seller does it and confirms, then the skill proceeds (and polls where a result is async).\n\n1. **[seller/browser] Fulfillment URL (AMMP change set).** AMMP → Products → SaaS → select the\n product → **Request changes → Update fulfillment options → edit the default Fulfillment\n URL** (this opens a change set). Set it to the **branded registration page** if ready and\n integrated with `RegistrationUrl`; if no page exists yet, set it to the raw `RegistrationUrl`\n (main-stack output) purely for testing. Submit the change set. → **Seller confirms submitted.**\n The change set takes time to apply; the skill/seller waits until the product is live again\n before subscribing (poll the product status or retry the subscribe page).\n2. **[seller/browser] Buyer allowlist.** A `Limited` product allowlists the SELLER account by\n default — to test as the seller, SKIP this. To test with a DIFFERENT buyer account: AMMP →\n the product → **Update allowlist** → add that AWS account ID → submit. → **Seller confirms\n the test account is allowlisted.**\n3. **[seller/browser] Subscribe.** Log into the TEST account (seller or the allowlisted buyer),\n open `https://aws.amazon.com/marketplace/pp?sku=\u003cPRODUCT_CODE>`, and subscribe. → **Seller\n confirms subscribed.**\n4. **[seller/browser] Set up your account.** After subscribing, click **Set up your account** —\n the buyer's browser is redirected to the Fulfillment URL (the branded page, which calls\n `RegistrationUrl`; or the raw endpoint in the fallback case). → **Seller confirms clicked.**\n The skill then POLLS the subscribers table for the resolved buyer + `licenseArn` (§1).\n\n## 1. Async waits — poll, don't read once\n\nSeveral results are NOT instantaneous. The skill polls with bounded backoff and reports\n\"still pending, retrying\" vs. a real failure — it does not read once and declare failure.\n\n- **After Set up your account:** `ResolveCustomer` persists the buyer immediately, but the\n `licenseArn` + `subscriptionStatus=active` come from the `License Updated` EventBridge event,\n which arrives after a short delay. Poll the subscribers table (us-east-1) until `licenseArn`\n is present. **Default poll contract: up to ~5 minutes total, at 15-second intervals (20\n attempts).** \"Timed out\" = `licenseArn` still absent after the last attempt; the skill then\n reports the timeout as a probable failure (check the fulfillment-URL wiring / the\n `License Updated` handler) rather than silently giving up. The bound/cadence is a default an\n operator MAY raise for a slow catalog.\n- **Pipeline runs on EventBridge schedules:** the discoverer is hourly and the submitter/\n submission-expiry run on `rate(5 minutes)`. To validate the natural end-to-end path, poll for\n the result on the **same default contract: up to ~5 minutes at 15-second intervals**, after\n which a still-absent result is reported as a timeout (not a silent pass). To validate\n immediately, the skill MAY manually invoke the discoverer (`meter` mode at the matching\n `hourOffset`) then the submitter — it SHALL tell the seller which it is doing (waiting for the\n real schedule vs. a manual trigger).\n\nPoll example (subscribers row):\n\n```bash\nfor i in $(seq 1 20); do\n aws dynamodb scan --table-name \u003cSTACK_PREFIX>-subscribers --region us-east-1 \\\n --query 'Items[?licenseArn].licenseArn.S' --output text | grep -q . && { echo \"licenseArn present\"; break; }\n echo \"waiting for License Updated... ($i)\"; sleep 15\ndone\n```\n\n## 2. Test cases (full-pipeline mode)\n\n| # | Case | How | Expected observable outcome |\n|---|------|-----|-----------------------------|\n| T1 | Registration persisted | complete §0; poll (§1) | subscribers row (PII-free) has `customerAWSAccountId` + `productCode`; allowlisted KYB/PII fields are on the in-region `customer-profile` row (keyed `licenseArn`+`customerAWSAccountId`), NOT the subscribers row |\n| T2 | License resolved / active | poll subscribers row | `licenseArn` populated; `subscriptionStatus=active` (from `License Updated`) |\n| T3 | Metering happy path | write ONE conforming row (Step 5) → discoverer→aggregator→cleanup→submitter | raw row `meteringStatus=Aggregated` (`... from \u003ccount> raw usage records`); `aggregated_usage` row `meteringStatus=Success` + `meteringRecordId` |\n| T4 | Reject: bad quantity | write a row with `quantity` = `-1` / `1.5` / `abc` | raw row `RejectedClientSide` + reason `NegativeQuantity`/`NonIntegerQuantity`/`NonNumericQuantity`; `UsageRecordRejected` metric (by reason) |\n| T5 | Reject: malformed sort key / precision | write rows exercising each condition (one code each) | `RejectedClientSide` with the specific code: millisecond/fractional suffix (`...:07.123`, `...:07.000`) or hour-truncated (`...T13`) → `MalformedTimestamp`; sort key empty or \u003c3 `#`-segments → `MalformedSortKey`; account/dimension segment ≠ the row's own attribute → `SortKeyMismatch` |\n| T6 | Reject: missing dimension | row with empty `dimension` | `RejectedClientSide` + `MissingDimension` |\n| T7 | Server-side invalid dimension | conforming row with a dimension NOT defined in the catalog | `BatchMeterUsage` fails the call with a request-level `InvalidUsageDimensionException` (server-authoritative); the submitter isolates the record → `aggregated_usage` row `meteringStatus=RejectedClientSide` reason `InvalidUsageDimensionException` + `BatchMeterUsageException` metric — fix the dimension in AMMP, no redeploy |\n| T8 | Idempotency / duplicate | re-run the discoverer for T3's hour after it already aggregated | no double-bill (`DuplicateRecord` on re-submit is deduped); cleanup clears `meteringPending` WITHOUT restamping a stale sum |\n| T9 | Age-out (raw, never aggregated) | a pending raw row whose hour is >24h old (or invoke an age-out shard) | raw row `meteringStatus=AggregationExpired`; `UsageAggregationExpired` metric |\n| T10 | Submission-expiry (aggregated, never submitted) | an `aggregated_usage` pending row >24h old (invoke submission-expiry) | row `meteringStatus=SubmissionExpired`; `UsageSubmissionExpired` metric |\n| T11 | Month-boundary (optional) | a previous-month bucket around the 1st (see the month-boundary behavior in `setup-path.md`) | previous-month records still submittable in-grace; expired only after 06:00 UTC on the 1st |\n| T12 | Seller-supplied cases | the seller's own dimensions/quantities/VMT allocations/multi-region | as the seller expects; the skill runs and verifies them |\n\n## 3. Test cases (direct-submit mode)\n\nDirect-submit has no raw table / discoverer / aggregator / cleanup. Substitute:\n\n| # | Case | How | Expected |\n|---|------|-----|----------|\n| D1–D2 | Registration / license | same as T1–T2 | same as T1–T2 |\n| D3 | Happy path | write a FINALIZED `aggregated_usage` record (PK `licenseArn`, SK `account#dimension#hour`, `quantity`, `meteringPending`=hour) → submitter | `meteringStatus=Success` + `meteringRecordId` |\n| D4 | Finalize-before-insert / duplicate | write a SECOND record for the same group after it submitted | second submit returns `DuplicateRecord`; later quantity NOT billed (confirms the finalize-before-insert contract) |\n| D5 | Submission-expiry | pending record >24h old (or post-grace previous-month) | `SubmissionExpired` + `UsageSubmissionExpired` metric |\n\n## 4. Clean up test data\n\nRemove test rows the seller inserted (and, if a non-seller test account was used, note the\nallowlist entry). The `aggregated_usage` audit rows are retained by design.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8340,"content_sha256":"648823c9e2d30ebed12037fd31aea8ed2805a26e92cfc39ccf5475f0a8035006"},{"filename":"references/troubleshooting.md","content":"## Troubleshooting\n\n### \"Product code mismatch\"\nThe entity ID (`prod-xxx`) is not the metering product code. Check `ResolveCustomer` response in Lambda logs for the actual product code, then update `ProductCode` parameter in `template.yaml` and redeploy.\n\n### \"InvalidUsageDimensionException\"\nThe dimension key doesn't match what's configured on the product. Verify dimensions in AMMP or via the Catalog API. Add missing dimensions in AMMP before retrying.\n\n### \"DisabledApiException\" from BatchMeterUsage/MeterUsage\nThe metering API is regional and supported in MOST — but not all — commercial regions. If a call returns `DisabledApiException`, the chosen region does not yet support the API (e.g. a newly launched region). This is NOT a code or IAM bug: meter in a supported region instead (see the [region-support list](https://docs.aws.amazon.com/marketplace/latest/developerguide/metering-regions.html#batchmeterusage-region-support)). The metering region is the seller's choice (the region where usage occurs, or the nearest supported region), and per-region dedup makes metering the same usage in multiple supported regions correct — there is no `InvalidEndpointRegionException` for a \"wrong\" supported region.\n\n### \"No usage metered\"\nMetering is driven by the usage table's `metering_pending` GSI, not by subscriber status.\nCheck:\n\n1. Is the usage writer setting `meteringPending` (the `%Y-%m-%dT%H` hour bucket) on rows as\n it writes them? The meter Lambda only meters rows that appear on that sparse GSI.\n2. Are rows written for a COMPLETED hour? The meter Lambda meters the `now-23h … now-1h`\n window and never the in-progress current hour.\n3. If you expected a subscriber row: the `License Updated` event (not `Purchase Agreement\n Created`) establishes/refreshes it. Check the subscription Lambda logs in us-east-1 and\n the DLQ for failed processing. A missing subscriber row does NOT block metering (the\n CA UsageRecord is built from the usage rows).\n\n### EventBridge events not arriving\nMarketplace events emit ONLY in `us-east-1`. Ensure the events stack is deployed to `us-east-1`, not the seller's preferred region.\n\n**Important:** EventBridge does NOT retroactively deliver past events. If you deploy the events stack late, any subscriptions that occurred before deployment are lost. For those customers, manually query their subscription status or have them re-click \"Set up your account\" to trigger ResolveCustomer.\n\n**Cross-region pattern (only when the metering region is NOT us-east-1):** If your main metering stack is in a different region (e.g., eu-west-1), the Meter/Register Lambdas read the us-east-1 subscribers table cross-region (already handled by `SUBSCRIBERS_TABLE_REGION=us-east-1`). **If your metering region IS us-east-1, no cross-region setup is needed — deploy both the events stack and the main stack in us-east-1.** us-east-1 is a supported `BatchMeterUsage` region.\n\n### \"ExpiredTokenException\" on ResolveCustomer\nThe registration token is **reusable until it expires (~4 hours) — it is NOT one-time-use**, so a buyer who hit an error on the registration page can retry with their ORIGINAL link as long as they are still within that ~4-hour window (re-resolving the same token returns the same result). Once the ~4h has elapsed the token is expired: `ResolveCustomer` returns `ExpiredTokenException`, and the buyer must re-click \"Set up your account\" from the AWS Marketplace console to mint a FRESH token (a full resubscribe is NOT required). A token that was somehow already consumed/invalidated returns `InvalidTokenException` — same fix. So if a buyer clicked \"Set up your account\" **days ago** and only retries now, the original link's token is long expired and will NOT resolve — have them click \"Set up your account\" again for a new token, then retry.\n\n### AccessDeniedException on ResolveCustomer\n**Root cause: IAM namespace mismatch.** ResolveCustomer uses `aws-marketplace:ResolveCustomer` — this is a DIFFERENT service namespace from `meteringmarketplace:BatchMeterUsage`. Granting `marketplace:*` or `meteringmarketplace:*` does NOT cover ResolveCustomer.\n\n**The fix:** Your Lambda role needs BOTH actions from DIFFERENT namespaces:\n\n- `aws-marketplace:ResolveCustomer` — for the registration flow\n- `aws-marketplace:BatchMeterUsage` — for metering (same namespace as ResolveCustomer, NOT meteringmarketplace)\n\n**Common mistake:** Seller grants `meteringmarketplace:*` thinking it covers all Marketplace APIs. It doesn't — ResolveCustomer is under `aws-marketplace:`, not `meteringmarketplace:`.\n\n> **Note:** Region — ResolveCustomer is regional but the region is the SELLER'S CHOICE; it does NOT have to match where the buyer uses the software or where `BatchMeterUsage` is called, and it does NOT gate metering. It is NOT us-east-1-only. The real constraint is the ACCOUNT (must be the publishing account — see below). If you get AccessDeniedException, the issue is almost always the IAM namespace (`aws-marketplace:`).\n\nWhen `ResolveCustomer` succeeds, it returns four fields — store ALL of them:\n\n- `CustomerAWSAccountId` — the buyer's AWS account ID (**use this for metering**)\n- `LicenseArn` — the license ARN for this agreement (**use this for metering**)\n- `CustomerIdentifier` — opaque string (store for reference, NOT for metering)\n- `ProductCode` — your metering product code (store for validation)\n\n### BatchMeterUsage returns UnprocessedRecords\nThe submitter retries the unprocessed records automatically ONCE. If any are STILL unprocessed after that retry, it does NOT raise or DLQ them — it logs an error, emits the **`UsageRecordUnprocessed`** metric, and leaves the records **pending for the next submitter run** (`rate(5m)`), so they are retried, not lost. Common causes: throttling, timestamp out of bounds (older than 24 hours), or a transient service issue. Because the submitter does not raise, this does NOT trip the Lambda `Errors` alarm — the dedicated **`*-usage-unprocessed`** alarm (on the `UsageRecordUnprocessed` metric) is the signal that records are repeatedly failing to meter. A persistent breach means the records are not clearing on retry; investigate the cause (and confirm they are not aging past the 24h window, which would then also trip `UsageSubmissionExpired`).\n\n### Subscription cancelled mid-hour — what happens to metering?\nIf usage occurred while the subscription was active, the metering record SHOULD be accepted. However, there's a timing window:\n\n- If `Purchase Agreement Ended` event was processed BEFORE your BatchMeterUsage call → you may get `CustomerNotSubscribed`\n- If your metering call arrives BEFORE the cancellation is processed → record is accepted normally\n- **Best practice:** Check subscription status in your DDB table before metering each customer. If inactive, skip them.\n- Usage from before cancellation that was already metered in prior hourly batches is billed normally.\n\n### Contract renewals — which event fires?\nOn contract renewal, a `License Updated` event fires via EventBridge (NOT `Purchase Agreement Created` and NOT a fabricated \"Purchase Agreement Renewed\" — no such event exists). **The existing LicenseArn does NOT change on renewal** — it persists across contract periods. The same CustomerAWSAccountId + LicenseArn you've been using continue to work. No metering code changes needed.\n\n**Key facts about renewals:**\n\n- LicenseArn stays the same ← do NOT update customer records\n- CustomerAWSAccountId stays the same\n- You receive `License Updated` event (not a new agreement event)\n- Continue metering with the same identifiers — no action required\n- If contract terms change (new dimensions), update entitlement checks only\n\n### RegisterUsage for flat-rate products\nRegisterUsage (NOT BatchMeterUsage) is for flat-rate subscription products (AMI, Container, or SaaS with fixed monthly fee):\n\n- Call periodically (e.g., hourly) to confirm subscription is active\n- Returns `Success` if active, `CustomerNotSubscribed` if expired\n- `ThrottlingException` if called too frequently (back off and retry)\n- Does NOT report quantity or usage — just validates subscription status\n- Primary use case: AMI and Container products; SaaS flat-rate products typically use EventBridge subscription events instead\n\n### ProductCode vs Product ID confusion\n`InvalidProductCode` usually means you're passing the entity ID (`prod-xxx`) instead of the metering product code. To find the correct code:\n\n```\naws marketplace-catalog describe-entity --catalog AWSMarketplace --entity-id \u003cprod-xxx> --region us-east-1\n```\n\nLook for the ProductCode field in the response (format varies, often alphanumeric like `4ml54db8vrmjuykaw1psroool`). The code is case-sensitive.\n\n### \"TimestampOutOfBoundsException\" — rejected submission\nThe timestamp in each UsageRecord must be within the **last 24 hours**.\n\n- ✅ Valid: any hour within the last 24 hours\n- ✅ Valid: e.g., if it's Aug 12 17:00, anything back to Aug 11 17:00\n- ❌ Invalid: anything older than 24 hours\n- ❌ Invalid: future timestamps\n\n**If you missed an hour:**\n\n- You can backfill up to 24 hours. Beyond 24h requires AWS Support.\n- Fix your hourly schedule to prevent future misses.\n- For historical corrections, contact AWS Support (Marketplace category).\n\n### ResolveCustomer fails silently — wrong account\nResolveCustomer must be called from the **same AWS account that published the product**. If your registration page is deployed in a different account, the token will not resolve — no buyer can onboard. This is not an IAM permission issue; it's an account-level restriction.\n\n### One bad timestamp rejects the entire batch\nBatchMeterUsage validates ALL records in a batch before accepting any. If even ONE record has a timestamp older than 24 hours, the ENTIRE batch is rejected with `TimestampOutOfBoundsException`. Always validate timestamps before sending. Remove invalid records and resubmit the rest.\n\n### GetEntitlements returns empty at onboarding\nDo NOT call GetEntitlements immediately after ResolveCustomer. A resolved token only means a customer identity was created — the agreement may not exist yet (it can happen before the agreement is finalized). Call GetEntitlements when you receive the `License Updated` EventBridge event instead.\n\n### Cancellation — flush during the window, no grace period\nThe wind-down spans two events, and the two subscriber status fields move independently:\n\n1. On `Purchase Agreement Ended` — set `agreementStatus = inactive` ONLY. Do NOT change\n `subscriptionStatus`, stop metering, or flush; the buyer may still be entitled until the\n license is deprovisioned.\n2. On `License Deprovisioned` (legacy SNS `unsubscribe-pending`) — set\n `subscriptionStatus = deprovisioning` + a `deprovisioningExpiry` (event time + ~1h). This OPENS\n the ~1-hour final-usage flush window: submit any remaining un-sent usage IMMEDIATELY. The\n pipeline meters this row during the window; the events-stack deprovision-cleanup Lambda\n (`rate(15m)`) then sets `subscriptionStatus = inactive` once `deprovisioningExpiry` has passed\n (time-based finalization — NOT the submitter).\n Once the server-side window closes, `BatchMeterUsage` returns `CustomerNotSubscribed`.\n3. Do NOT invert the events (`License Deprovisioned`, not `Purchase Agreement Ended`, opens\n the flush window) and do NOT discard queued records before flushing. There is no\n `deprovisioned` status value. Do NOT rely on a grace period beyond that ~1-hour window.\n\n### `ResourceInUseException` on CreateTable when re-deploying after a rollback\nThe main stack's `UsageTable` uses `DeletionPolicy: Retain` / `UpdateReplacePolicy: Retain` (intentional — never lose billing usage). If a first deploy fails and rolls back, the usage table is **retained** and left behind, so the next deploy's `dynamodb:CreateTable` collides with the existing table and fails with `ResourceInUseException`. On a **failed first deploy the retained `\u003cprefix>-usage` table is empty** — delete it, then re-deploy:\n\n```bash\naws dynamodb delete-table --table-name \u003cprefix>-usage --region \u003cmetering-region>\n```\n\nOnly do this for a failed/rolled-back initial deploy. Never delete a usage table that has already recorded billable usage.\n\n### Monitoring & alarms (seller-owned)\nThe deployed stack creates `awsmp-*` CloudWatch metrics/alarms in the SELLER's account for\nthe revenue-loss signals: per-stage Lambda `Errors` and `Throttles` (discoverer/aggregator/cleanup/submitter/expiry), a \"meter did not run\"\n(low `Invocations`) alarm, two `>24h` usage age-out alarms by stage (EMF `UsageAggregationExpired` for raw usage never aggregated, `UsageSubmissionExpired` for aggregated records never submitted),\nplus register/subscription-error and DLQ-depth alarms on the events stack. Business-status\nmetrics (aged-out, rejected, `UsageRecordUnprocessed`, `CustomerNotSubscribed`, `DuplicateRecord`, `BatchMeterUsageException`)\nare emitted via CloudWatch Embedded Metric Format (EMF) from the pipeline Lambda logs — no extra\nruntime IAM.\n\n**Alarms that tell you records are being rejected / not metered** (each fires into `AlertsTopicArn`):\n\n- **`*-usage-unprocessed`** (`UsageRecordUnprocessed`) — records still unprocessed after the submitter's one retry (the ONLY alarm-able signal for the `UnprocessedRecords` case; does not trip the `Errors` alarm).\n- **`*-usage-rejected`** (`UsageRecordRejected`) — client-side guardrail rejections (summed across all reasons).\n- **`*-batchmeterusage-exception`** (`BatchMeterUsageException`) — request-level exception failed a whole call (dimension/license/etc.).\n- **`*-customer-not-subscribed`** (`CustomerNotSubscribed`) — per-record status, no active subscription.\n- The `>24h` age-out alarms (`UsageAggregationExpired`/`UsageSubmissionExpired`) catch usage that silently never metered. `DuplicateRecord` is dashboard-only (informational), not an alarm.\n\n- Alarms are ALWAYS created. Each alarm's actions wire to an OPTIONAL seller-supplied\n in-region SNS topic (`AlertsTopicArn` on each stack); a CloudWatch alarm can only notify a\n topic in its own region, so use a us-east-1 topic for the events stack and a\n metering-region topic for the main stack.\n- Neither the deployer role nor any Lambda publishes to the topic — CloudWatch fires the\n action. **Handling alarms and acting on failures is the seller's responsibility**; AWS\n Marketplace and this skill do not track client-side (seller-account) errors.\n- Thresholds, the SNS topic, retention, and KMS keys are seller customization points (the\n `TODO:` parameters in the templates).\n\n### Legacy (non-LicenseArn) integrations — assume nothing about the seller's stack\nThe packaged meter Lambda and usage-table schema describe the skill's OWN new Concurrent\nAgreements integration. For a legacy `ProductCode`-based integration (no `LicenseArn`), do\nNOT assume that schema or any specific storage: how usage is stored, where\n`ProductCode`/`CustomerIdentifier` come from, how subscriptions are tracked, and how the\nfinal-hour flush is handled all depend on the seller's EXISTING stack. First understand the\nseller's integration (ask questions, read their code/config) and ground legacy guidance in\nthat plus the public AWS documentation. Preserve only the API-level invariants (call\n`BatchMeterUsage` with `ProductCode` at the request level and `CustomerIdentifier` in\nUsageRecords; the 24h window and per-region dedup; never silently skip customers). Do NOT\nimpose the CA usage-table keys, the `metering_pending` GSI, `registeredRegions`, or the\n`agreementStatus`/`subscriptionStatus` fields on a legacy stack unless it already uses them.\n\n### Client-side rejection reason codes (`UsageRecordRejected`)\n\nWhen usage is rejected BEFORE it is sent to `BatchMeterUsage`, the meter Lambda records a\nterminal `RejectedClientSide` status plus a specific reason on the usage row, and emits the\n`UsageRecordRejected` CloudWatch metric (dimensioned by `Reason` + `ProductCode`, watched by\nthe `awsmp-*-usage-rejected` alarm). A rejected row was never sent, so its dedup grain is\nuntouched — **fix the source row and re-write it within 24h and it meters normally.** The\nreason codes and what to fix:\n\n> For the COMPLETE inventory of client-side guardrails — not just these reason codes, but\n> also the timing/window, batch-shaping, and idempotency guardrails applied before\n> `BatchMeterUsage` — see `references/client-side-guardrails.md`.\n\n| Reason | What it means / what to fix |\n|--------|-----------------------------|\n| `NegativeQuantity` | `quantity` is negative. Write non-negative integers. |\n| `NonIntegerQuantity` | `quantity` is fractional/decimal (e.g. `2.5`). Aggregate/round in your writer to an integer before persisting — it is NOT truncated for you. |\n| `NonNumericQuantity` | `quantity` is not a number. |\n| `MissingDimension` | The row has no `dimension`. |\n| `TimestampInFuture` / `TimestampOutOfWindow` | The metering hour is in the future or older than the 24h acceptance window. |\n| `MalformedTimestamp` | The raw-usage sort-key suffix is not an exact second-precision `YYYY-MM-DDTHH:MM:SS` timestamp (e.g. millisecond/fractional or hour-truncated). Write the suffix at whole-second precision. |\n| `MalformedSortKey` | The sort key is missing or has fewer than the three `customerAWSAccountId#dimension#timestamp` segments. |\n| `SortKeyMismatch` | The sort key's account/dimension segments don't match the row's own `customerAWSAccountId`/`dimension` attributes (corrupt/mis-keyed row). |\n| `MissingCustomerAWSAccountId` | CA row without `customerAWSAccountId`. |\n| `MissingLicenseArn` | CA path without a real `licenseArn`. |\n| `InvalidLicenseArn` | `licenseArn` is not a syntactically valid License Manager ARN. Rejected client-side so it never poisons a batch. |\n| `MissingLegacyIdentifier` | Legacy row with neither `CustomerIdentifier` nor `CustomerAWSAccountId`. |\n| `MixedAllocatedAndUnallocated` | Within one `(license, account, dimension, hour)` group, some non-zero rows carry `usageAllocations` and some don't. Make the group all-tagged or all-untagged. |\n| `AllocationSumMismatch` | The merged `AllocatedUsageQuantity` sum ≠ the record `Quantity`. Ensure each row's allocations sum to that row's quantity. |\n| `TooManyTags` | An allocation has more than 5 tags. |\n| `TooManyAllocations` | The merged group produced more than 2500 distinct tag-set allocations for one record. Reduce the tag cardinality per hour. |\n| `MissingTagKeyOrValue` / `MalformedAllocations` | A tag lacks a non-empty Key/Value, or `usageAllocations` is not the expected list-of-objects shape. |\n\n### One bad LicenseArn does not block the batch\nA malformed/never-valid `LicenseArn` is returned by `BatchMeterUsage` as a request-level\n`InvalidLicenseException` (not a per-record status). The meter Lambda validates the\nLicenseArn shape client-side (rejecting `InvalidLicenseArn` before batching) and, if a\nrequest-level error still occurs, ISOLATES the offending record by bisecting the batch\n(bounded to ⌈log2(batch size)⌉ = 5 levels; at the cap a still-failing sub-batch is left pending for the next run),\nso the other co-batched customers are still metered. A true `CustomerNotSubscribed` (valid\nLicenseArn, ended/expired agreement, or suspended buyer) is a per-record status handled\nterminally — do not retry it.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":19387,"content_sha256":"b42a36cabe0ac09bc30b6702867953afd4507d35f661c02db5e9d545af590a61"},{"filename":"scripts/aggregator.py","content":"\"\"\"Aggregator Lambda.\n\nSQS-triggered (the work queue), one message = one (licenseArn, account, dimension, hour)\ngroup. Per group:\n\n 1. Read the group's rows via the targeted begins_with prefix (streaming fold), sum the\n quantity, merge seller-provided VMT usageAllocations, and validate.\n 2. INVALID -> reject-and-finalize CLIENT-SIDE here (not in the submitter): terminal\n RejectedClientSide + reason on each raw row, clear meteringPending, emit the\n UsageRecordRejected EMF metric. The group never reaches the submit path.\n 3. VALID -> write ONE aggregated record into aggregated_usage via a CONDITIONAL\n PutItem (attribute_not_exists) — the idempotency commit point — carrying\n meteringPending so the submitter can find it. Then enqueue cleanup messages\n (\u003c=100 raw row keys each) so the cleanup Lambda clears meteringPending on the raw\n rows asynchronously.\n\nIdempotency: if the conditional PutItem fails (ConditionalCheckFailedException) the group\nwas already aggregated by a prior visit; skip the put AND skip enqueuing cleanup (inflation\nguard). collect_group only returns rows still carrying meteringPending, so an already-cleaned\ngroup yields no row_keys and no-ops earlier; a genuinely-lost cleanup self-heals on the next\nscheduled re-visit (rows still pending are re-detected and cleaned), not by re-enqueuing on\nevery duplicate visit.\n\nZero-quantity handling: an in-window 0-quantity group is left pending\n(submitting 0 early would lock the hour at 0 via first-write-wins); at the oldest edge\n(hour == now-23h) it is aggregated as Quantity 0.\n\"\"\"\n\nimport json\nimport os\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any, Dict, List\n\nimport boto3\nfrom botocore.exceptions import ClientError\n\nfrom handlers import metering_core as core\n\nlogger = core.logger\n\nsqs = boto3.client(\"sqs\")\nCLEANUP_QUEUE_URL = os.environ.get(\"CLEANUP_QUEUE_URL\", \"\")\n\n\ndef handler(event, context):\n if core.aggregated_usage_table is None:\n raise RuntimeError(\"AGGREGATED_USAGE_TABLE not configured\")\n if not CLEANUP_QUEUE_URL:\n raise RuntimeError(\"CLEANUP_QUEUE_URL not configured\")\n\n now = datetime.now(timezone.utc)\n failures = []\n for record in event.get(\"Records\", []):\n try:\n _process_message(json.loads(record[\"body\"]), now)\n except Exception as e:\n # Report partial batch failure so only the failed message is retried/DLQ'd.\n logger.error(f\"Aggregation failed for a group: {type(e).__name__}: {e}\")\n failures.append({\"itemIdentifier\": record[\"messageId\"]})\n return {\"batchItemFailures\": failures}\n\n\ndef _process_message(msg, now):\n license_arn = msg[\"licenseArn\"]\n account_id = msg[\"customerAWSAccountId\"]\n dimension = msg[\"dimension\"]\n hour_bucket = msg[\"hourBucket\"]\n\n group = core.collect_group(license_arn, account_id, dimension, hour_bucket)\n if not group[\"row_keys\"]:\n # Nothing pending (already cleaned up by a prior visit) — no-op, idempotent.\n logger.info(\n f\"No pending rows for group license={core.mask(license_arn)} \"\n f\"dim={dimension} hour={hour_bucket}; skipping\"\n )\n return\n\n # In-window zero-quantity group: leave pending (do not aggregate yet). At the oldest\n # edge it falls through and is aggregated as Quantity 0.\n oldest_edge = now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=23)\n if group[\"quantity\"] == 0 and not group[\"reject_reason\"] and group[\"hour_ts\"] > oldest_edge:\n logger.info(\n f\"In-window zero-quantity group left pending license={core.mask(license_arn)} \"\n f\"dim={dimension} hour={hour_bucket}\"\n )\n return\n\n reason = core.validate_group(group, now)\n if reason:\n _reject(group, reason)\n return\n\n written = _write_aggregated(group)\n if written:\n # Fresh write — enqueue cleanup carrying the authoritative Aggregated status + sum/count.\n _enqueue_cleanup(group, stamp_status=True)\n else:\n # Duplicate/concurrent visit: a PRIOR aggregator run already wrote the aggregated\n # record. Do NOT enqueue a (status-less) cleanup here — that only inflates the cleanup\n # queue. collect_group already filters to rows still carrying meteringPending, so if the\n # prior cleanup succeeded these rows would have been absent and we'd have no-op'd above;\n # if a prior cleanup was genuinely lost, the rows still carry meteringPending and are\n # re-discovered on the NEXT scheduled sweep, which re-detects and cleans them (one-cycle\n # self-heal) — without a status-less enqueue on every duplicate visit.\n logger.info(\n f\"Aggregated record already exists (idempotent) for license={core.mask(license_arn)} \"\n f\"dim={dimension} hour={hour_bucket}; NOT re-enqueuing cleanup (inflation guard). \"\n \"Any still-pending rows self-heal on the next scheduled re-visit.\"\n )\n\n\ndef _write_aggregated(group) -> bool:\n \"\"\"Conditional PutItem into aggregated_usage. Returns True if written, False if it\n already existed (ConditionalCheckFailedException — idempotent commit point).\"\"\"\n item = {\n \"licenseArn\": group[\"license_arn\"],\n \"account_dimension_hour\": core.agg_sort_key(\n group[\"account_id\"], group[\"dimension\"], group[\"hour_bucket\"]\n ),\n \"customerAWSAccountId\": group[\"account_id\"],\n \"dimension\": group[\"dimension\"],\n \"hourBucket\": group[\"hour_bucket\"],\n \"quantity\": group[\"quantity\"],\n \"meteringPending\": group[\"hour_bucket\"],\n }\n allocations = core.finalize_allocations(group)\n if allocations:\n item[\"usageAllocations\"] = allocations\n if group.get(\"customer_identifier\"):\n item[\"customerIdentifier\"] = group[\"customer_identifier\"]\n try:\n core.aggregated_usage_table.put_item(\n Item=core.audit_item(item),\n ConditionExpression=\"attribute_not_exists(licenseArn)\",\n )\n return True\n except ClientError as e:\n if e.response.get(\"Error\", {}).get(\"Code\") == \"ConditionalCheckFailedException\":\n return False\n raise\n\n\ndef _reject(group, reason):\n \"\"\"Reject-and-finalize CLIENT-SIDE: terminal status + reason on every raw\n row of the group, clear meteringPending, WARN, emit the UsageRecordRejected metric.\n The rejected group is never aggregated or submitted. Writes use the shared bounded\n thread pool + backoff (a rejected group is up to ~3600 same-licenseArn-partition\n UpdateItems — same hot-partition guard as cleanup), and raise on any unrecoverable\n failure so the message is retried (SQS redelivery/DLQ), never silently half-rejected.\"\"\"\n logger.warning(\n \"Rejecting usage group client-side \"\n f\"(license={core.mask(group['license_arn'])} dim={group['dimension']} \"\n f\"hour={group['hour_bucket']}): {reason}\"\n )\n core.emf_metric(\"UsageRecordRejected\", 1, reason=reason)\n core.update_rows_bounded(\n group[\"license_arn\"],\n group[\"row_keys\"],\n \"REMOVE meteringPending SET meteringStatus = :st, meteringStatusReason = :rsn\",\n {\":st\": core.STATUS_REJECTED, \":rsn\": reason},\n )\n\n\ndef _enqueue_cleanup(group, stamp_status):\n \"\"\"Enqueue cleanup messages carrying ≤100 raw row keys each (PK licenseArn + SKs).\n\n stamp_status=True (rule a — THIS invocation freshly wrote the aggregated record):\n include meteringStatus=\"Aggregated\", totalQuantity, and recordCount so the cleanup\n Lambda stamps the authoritative \"Aggregated total quantity \u003csum> from \u003ccount> raw\n usage records\" reason. Only the writer knows the true sum/count.\n stamp_status=False (rule b — the record already existed; duplicate/concurrent visit):\n carry ONLY the row keys and NO status — cleanup clears meteringPending only, never\n stamping a sum that may differ from what the true writer persisted.\n\n Uses SendMessageBatch (≤10 entries/call, like the discoverer); a partial Failed result\n raises so the message is retried (a lost cleanup self-heals on the next hourly re-visit).\"\"\"\n license_arn = group[\"license_arn\"]\n keys = group[\"row_keys\"]\n entries: List[Dict[str, Any]] = []\n for i in range(0, len(keys), core.CLEANUP_KEYS_PER_MESSAGE):\n chunk = keys[i : i + core.CLEANUP_KEYS_PER_MESSAGE]\n body = {\"licenseArn\": license_arn, \"rowKeys\": chunk}\n if stamp_status:\n body[\"meteringStatus\"] = \"Aggregated\"\n body[\"totalQuantity\"] = group[\"quantity\"]\n body[\"recordCount\"] = len(keys)\n entries.append({\"Id\": str(len(entries)), \"MessageBody\": json.dumps(body)})\n if len(entries) == 10: # SQS SendMessageBatch max 10 entries/call\n _flush_cleanup_batch(entries)\n entries = []\n if entries:\n _flush_cleanup_batch(entries)\n\n\ndef _flush_cleanup_batch(entries):\n resp = sqs.send_message_batch(QueueUrl=CLEANUP_QUEUE_URL, Entries=entries)\n failed = resp.get(\"Failed\", [])\n if failed:\n raise RuntimeError(f\"SQS SendMessageBatch had {len(failed)} cleanup failure(s): {failed}\")\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9213,"content_sha256":"c9fb245bf6d2f8ac32af592bb2ecd7034d6f2b608146f8a41a483491b22546c0"},{"filename":"scripts/cleanup.py","content":"\"\"\"Cleanup Lambda.\n\nSQS-triggered (the cleanup queue). Each message carries a licenseArn and ≤100 raw usage row\nsort keys (PK+SK). A message MAY also carry meteringStatus=\"Aggregated\" + totalQuantity +\nrecordCount — present ONLY when the enqueuing aggregator invocation FRESHLY WROTE the\naggregated record (so it knows the authoritative sum/count). Two message shapes:\n\n (d) WITH meteringStatus=\"Aggregated\": per raw row clear meteringPending AND set\n meteringStatus=\"Aggregated\" + meteringStatusReason=\"Aggregated total quantity \u003csum>\n from \u003ccount> raw usage records\". Performed UNCONDITIONALLY (even if meteringPending was\n already cleared by another/duplicate process) so the authoritative status always lands.\n (e) WITHOUT a status (a duplicate/concurrent aggregator visit found the record already\n present): clear meteringPending ONLY — never stamp a sum that may differ from what the\n true writer persisted.\n\nThis makes a successfully-aggregated raw row observably distinct from a never-processed row\n(previously only RejectedClientSide and AggregationExpired rows carried a status). The raw-row\nmeteringStatus domain is therefore {Aggregated, RejectedClientSide, AggregationExpired}; the\nSUBMISSION outcome (meteringRecordId / Success / CustomerNotSubscribed / …) is NOT written\nhere — it lives only on the aggregated_usage record (the submitter owns it).\n\nWhy per-key UpdateItem and not BatchWriteItem\n---------------------------------------------\nDynamoDB BatchWriteItem only supports full-item Put/Delete — it CANNOT partially update a\nsingle attribute. Rewriting the whole item via a PutRequest would clobber any concurrent\nseller write to that row. So we apply a surgical\n``UpdateItem ... REMOVE meteringPending SET meteringStatus, meteringStatusReason``\n(idempotent: re-running overwrites the same values).\n\nAvoiding a Lambda timeout without hot-spotting a partition\n----------------------------------------------------------\nTo get batch-like speed we issue the per-key UpdateItems concurrently with a BOUNDED\nthread pool (CLEANUP_MAX_WORKERS, default 16) rather than serially. Every row of a group\nshares the same ``licenseArn`` partition key, so a burst hits ONE DynamoDB partition\n(~1000 WCU/sec limit). Bounded concurrency + the \u003c=100-keys-per-message chunk keep\ninstantaneous WCU under that limit; a throttling error is retried with exponential\nbackoff. A key that still fails after retries is NOT swallowed — the whole message is\nfailed (raising) so SQS redelivers it and it eventually lands on the DLQ; cleanup is never\nsilently dropped (the raw row simply keeps meteringPending and is re-discovered, where the\naggregator's conditional put no-ops and re-enqueues cleanup).\n\nCleanup runs only AFTER the aggregator durably wrote the aggregated_usage record, so\nfinalizing the raw rows here never loses un-aggregated usage.\n\"\"\"\n\nimport json\n\nfrom handlers import metering_core as core\n\nlogger = core.logger\n\n\ndef handler(event, context):\n failures = []\n for record in event.get(\"Records\", []):\n try:\n _process_message(json.loads(record[\"body\"]))\n except Exception as e:\n logger.error(f\"Cleanup failed for a message: {type(e).__name__}: {e}\")\n failures.append({\"itemIdentifier\": record[\"messageId\"]})\n return {\"batchItemFailures\": failures}\n\n\ndef _process_message(msg):\n license_arn = msg[\"licenseArn\"]\n row_keys = [k for k in msg.get(\"rowKeys\", []) if k]\n if not row_keys:\n return\n\n status = msg.get(\"meteringStatus\") # present only for a FRESH-write cleanup (rule a)\n if status == \"Aggregated\":\n # Rule (d): the aggregator freshly wrote the record, so this message carries the\n # authoritative sum + count. Clear meteringPending AND set the positive terminal\n # bookkeeping status/reason. UNCONDITIONAL — performed even if meteringPending was\n # already cleared by another process (a duplicate visit) — so the\n # authoritative status/reason always lands. Idempotent per-key UpdateItem.\n total_quantity = msg.get(\"totalQuantity\", 0)\n record_count = msg.get(\"recordCount\", len(row_keys))\n reason = f\"Aggregated total quantity {total_quantity} from {record_count} raw usage records\"\n core.update_rows_bounded(\n license_arn,\n row_keys,\n \"REMOVE meteringPending SET meteringStatus = :st, meteringStatusReason = :rsn\",\n {\":st\": \"Aggregated\", \":rsn\": reason},\n )\n logger.info(\n f\"Finalized {len(row_keys)} raw row(s) as Aggregated ({reason}) \"\n f\"for license={core.mask(license_arn)}\"\n )\n else:\n # Rule (e): a status-less cleanup message (duplicate/concurrent visit).\n # Only clear meteringPending so the row leaves the sparse GSI; do NOT stamp a status\n # or a sum that might differ from what the true writer persisted.\n core.update_rows_bounded(\n license_arn,\n row_keys,\n \"REMOVE meteringPending\",\n {},\n )\n logger.info(\n f\"Cleared meteringPending on {len(row_keys)} raw row(s) (no status) \"\n f\"for license={core.mask(license_arn)}\"\n )\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5241,"content_sha256":"d5d9adb90369b998fc93c0c4b667d2912d1bd24ab5ed1cc9dac234fc6a6b058b"},{"filename":"scripts/deploy.sh","content":"#!/bin/bash\nset -euo pipefail\n\n# AWS Marketplace SaaS Metering Integration - Deploy Script\n#\n# Usage:\n# ./deploy.sh events-only Deploy shared events stack only\n# ./deploy.sh \u003cPRODUCT_CODE> \u003cREGION> Deploy events + main stack\n# ./deploy.sh \u003cPRODUCT_CODE> \u003cREGION> \u003cPREFIX> Custom stack prefix\n#\n# Examples:\n# STAGE_NAME=v1 ./deploy.sh events-only\n# STAGE_NAME=v1 ./deploy.sh abc123def us-west-2\n# STAGE_NAME=beta METERING_MODE=dry-run TEST_ACCOUNT_ALLOWLIST=111122223333 ./deploy.sh abc123def us-west-2 myapp-abc123def-beta\n#\n# Stack names (stage-scoped; STAGE_NAME is mandatory):\n# Events stack (shared per stage): awsmp-events-\u003cSTAGE_NAME>-stack (us-east-1)\n# Main stack (per-product): \u003cPREFIX>-metering, default awsmp-\u003cPRODUCT_CODE>-\u003cSTAGE_NAME>-metering (\u003cREGION>)\n\n# ─── Cleanup trap ────────────────────────────────────────────────────────────\ncleanup() {\n local exit_code=$?\n if [ $exit_code -ne 0 ]; then\n echo \"\"\n echo \"⚠️ Deployment failed (exit code: $exit_code)\"\n echo \" Check the CloudFormation console for stack events.\"\n echo \" No automatic rollback — review and fix before retrying.\"\n fi\n # Clean up SAM build artifacts if they exist\n if [ -d \".aws-sam\" ]; then\n rm -rf .aws-sam\n fi\n exit $exit_code\n}\ntrap cleanup EXIT\n\n# ─── Version checks ─────────────────────────────────────────────────────────\ncheck_prerequisites() {\n local errors=0\n\n # AWS CLI v2 required\n if ! command -v aws &>/dev/null; then\n echo \"ERROR: AWS CLI not found. Install from https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html\"\n errors=$((errors + 1))\n else\n local aws_version\n aws_version=$(aws --version 2>&1 | sed -n 's/.*aws-cli\\/\\([0-9]*\\).*/\\1/p')\n if [ \"${aws_version:-0}\" -lt 2 ]; then\n echo \"ERROR: AWS CLI v2 required (found v${aws_version}). Update: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html\"\n errors=$((errors + 1))\n fi\n fi\n\n # SAM CLI required\n if ! command -v sam &>/dev/null; then\n echo \"ERROR: SAM CLI not found. Install from https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html\"\n errors=$((errors + 1))\n fi\n\n # Python 3.9+ required (for Lambda runtime compatibility)\n if ! command -v python3 &>/dev/null; then\n echo \"ERROR: Python 3 not found.\"\n errors=$((errors + 1))\n else\n local py_version\n py_version=$(python3 -c \"import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')\")\n local py_minor\n py_minor=$(echo \"$py_version\" | cut -d. -f2)\n if [ \"${py_minor:-0}\" -lt 9 ]; then\n echo \"WARNING: Python 3.9+ recommended (found $py_version). Lambda uses 3.12.\"\n fi\n fi\n\n if [ $errors -gt 0 ]; then\n echo \"\"\n echo \"Fix the above errors before proceeding.\"\n exit 1\n fi\n}\n\n# ─── Input validation ─────────────────────────────────────────────\n# Validate caller-supplied values against a strict allowlist charset BEFORE use, so a\n# value containing spaces/shell metacharacters cannot inject extra CloudFormation\n# parameter overrides or arguments. Product code / prefix: alphanumeric, dash, underscore.\n# Region: AWS region format.\nvalidate_inputs() {\n local product_code=\"$1\"\n local region=\"$2\"\n local prefix=\"$3\"\n\n if ! [[ \"$product_code\" =~ ^[A-Za-z0-9_-]+$ ]]; then\n echo \"ERROR: invalid PRODUCT_CODE '$product_code' (allowed: A-Z a-z 0-9 _ -).\"\n exit 1\n fi\n if ! [[ \"$region\" =~ ^[a-z]{2}-[a-z]+-[0-9]$ ]]; then\n echo \"ERROR: invalid REGION '$region' (expected e.g. us-east-1).\"\n exit 1\n fi\n if ! [[ \"$prefix\" =~ ^[A-Za-z0-9_-]+$ ]]; then\n echo \"ERROR: invalid PREFIX '$prefix' (allowed: A-Z a-z 0-9 _ -).\"\n exit 1\n fi\n}\n\n# ─── SAM artifact bucket (no --resolve-s3) ──────────────────────────\n# The least-privilege deployer policy scopes cloudformation:* to stack/awsmp-*, so\n# `sam deploy --resolve-s3` (which bootstraps the non-awsmp aws-sam-cli-managed-default\n# CFN stack) is DENIED. Provision the artifact bucket DIRECTLY instead — the policy's\n# S3DeploymentArtifacts grant already allows this on aws-sam-cli-managed-default-*.\n# Echoes the bucket name for use with `sam deploy --s3-bucket`.\nensure_sam_bucket() {\n local region=\"$1\"\n local bucket=\"aws-sam-cli-managed-default-${ACCOUNT_ID}-${region}\"\n if ! aws s3api head-bucket --bucket \"$bucket\" --region \"$region\" >/dev/null 2>&1; then\n if [ \"$region\" = \"us-east-1\" ]; then\n aws s3api create-bucket --bucket \"$bucket\" --region \"$region\" >/dev/null\n else\n aws s3api create-bucket --bucket \"$bucket\" --region \"$region\" \\\n --create-bucket-configuration \"LocationConstraint=${region}\" >/dev/null\n fi\n aws s3api put-bucket-encryption --bucket \"$bucket\" \\\n --server-side-encryption-configuration \\\n '{\"Rules\":[{\"ApplyServerSideEncryptionByDefault\":{\"SSEAlgorithm\":\"AES256\"}}]}' >/dev/null\n aws s3api put-bucket-versioning --bucket \"$bucket\" \\\n --versioning-configuration Status=Enabled >/dev/null\n fi\n echo \"$bucket\"\n}\n\n# ─── Per-region API Gateway CloudWatch Logs account role ────────────\n# A logging-enabled API stage (AccessLogSetting) requires a per-region,\n# account-level CloudWatch Logs role set via `apigateway update-account`. Granting the\n# permission is not enough — the role/account setting must actually be provisioned per\n# region, or the first region \"just works\" and every additional region fails with\n# \"CloudWatch Logs role ARN must be set in account settings to enable logging\".\n# Idempotent: creates the (region-agnostic) role once, then sets the account setting for\n# THIS region if not already set.\nensure_apigw_cw_role() {\n local region=\"$1\"\n local role_name=\"awsmp-apigw-cloudwatch-logs\"\n local role_arn=\"arn:aws:iam::${ACCOUNT_ID}:role/${role_name}\"\n\n if ! aws iam get-role --role-name \"$role_name\" >/dev/null 2>&1; then\n aws iam create-role --role-name \"$role_name\" \\\n --assume-role-policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"apigateway.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}' \\\n >/dev/null\n aws iam attach-role-policy --role-name \"$role_name\" \\\n --policy-arn arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs \\\n >/dev/null\n # IAM role propagation to API Gateway can lag a few seconds.\n sleep 10\n fi\n\n local current\n current=$(aws apigateway get-account --region \"$region\" \\\n --query cloudwatchRoleArn --output text 2>/dev/null || echo \"None\")\n if [ \"$current\" = \"None\" ] || [ -z \"$current\" ]; then\n echo \"Setting API Gateway account CloudWatch Logs role for ${region} (account-global for that region)...\"\n aws apigateway update-account --region \"$region\" \\\n --patch-operations \"op=replace,path=/cloudwatchRoleArn,value=${role_arn}\" >/dev/null\n fi\n}\n\n# ─── Failed-deploy recovery preflight (detect-and-guide only) ────────\n# After a failed deploy/rollback, DeletionPolicy: Retain resources (usage/subscribers\n# tables, subscription role, /aws/lambda/awsmp-* log groups) and an orphaned baseline\n# WebACL can survive and collide on retry (ResourceExistenceCheck / WebACL AlreadyExists).\n# This is READ-ONLY: it DETECTS likely collisions and prints guidance, then continues.\n# It NEVER auto-deletes; the seller removes reviewed resources with their own credentials\n# (a data-bearing table requires explicit confirmation).\nwarn_retained_resources() {\n local prefix=\"$1\"\n local region=\"$2\"\n local found=0\n # Log groups are the easy-to-miss case (not shown in the rollback resource summary).\n local lg\n lg=$(aws logs describe-log-groups --region \"$region\" \\\n --log-group-name-prefix \"/aws/lambda/${prefix}-\" \\\n --query 'logGroups[].logGroupName' --output text 2>/dev/null || true)\n if [ -n \"$lg\" ] && [ \"$lg\" != \"None\" ]; then\n echo \"NOTE: pre-existing log group(s) may collide on re-create: $lg\"\n found=1\n fi\n if aws dynamodb describe-table --table-name \"${prefix}-usage\" --region \"$region\" >/dev/null 2>&1; then\n echo \"NOTE: usage table '${prefix}-usage' already exists (retained). If this is a\"\n echo \" re-deploy after a FAILED first deploy the table is empty and can be removed;\"\n echo \" if it holds billable usage do NOT delete it. Deleting requires your explicit\"\n echo \" action with your own credentials — this script will not delete it.\"\n found=1\n fi\n if [ $found -eq 1 ]; then\n echo \" Review and remove the reviewed leftover resource(s), then retry.\"\n fi\n}\n\n# ─── Materialize-then-deploy parity preflight ────────────────────────────────\n# This script deploys the on-disk SAM template(s) the skill materialized into the\n# workspace (`sam build --template-file \u003cname>` below), so the deployed stack always\n# matches the source the seller can see and further develop. Fail fast (READ-ONLY, no\n# mutation) if the template we are about to build is missing or renamed — that would\n# mean a drift between \"what was generated\" and \"what gets deployed\". We do NOT recreate\n# or relocate it; the seller/skill regenerates it in place.\nrequire_template() {\n local tmpl=\"$1\"\n if [ ! -f \"$tmpl\" ]; then\n echo \"ERROR: expected on-disk template '$tmpl' not found in $(pwd).\" >&2\n echo \" deploy.sh deploys the materialized template so the stack matches your\" >&2\n echo \" source. Generate/restore '$tmpl' (and its Lambda handlers) here,\" >&2\n echo \" then re-run — do not deploy a template that is not left in the workspace.\" >&2\n exit 1\n fi\n}\n\n# ─── Deploy events stack (shared, us-east-1) ─────────────────────────────────\n# ─── Permissions-boundary preflight ──────────────────────────────────────────\n# SAM auto-generates the Lambda execution roles; the deployer role only allows\n# iam:CreateRole when a matching permissions boundary is attached. Fail fast with a\n# clear message (instead of an opaque AccessDenied mid-deploy) if the boundary policy\n# named by BOUNDARY_NAME does not exist in the target account.\nBOUNDARY_NAME=\"${BOUNDARY_NAME:-awsmp-metering-boundary}\"\n\nverify_boundary_exists() {\n local acct boundary_arn err\n # Validate the account id before building the ARN — an empty/None value would produce a\n # malformed ARN and a misleading \"not found\" message. `|| true` so a failing STS call\n # falls through to the guard below instead of aborting the script under `set -e`.\n acct=$(aws sts get-caller-identity --query Account --output text 2>/dev/null || true)\n if [ -z \"$acct\" ] || [ \"$acct\" = \"None\" ]; then\n echo \"ERROR: unable to resolve AWS account id (check credentials/region).\"\n exit 1\n fi\n boundary_arn=\"arn:aws:iam::${acct}:policy/${BOUNDARY_NAME}\"\n\n # Capture stderr so we can distinguish a genuinely-missing policy (NoSuchEntity) from\n # a permissions gap (AccessDenied). Collapsing every failure into \"not found\" would\n # block deploys when the deployer merely lacks iam:GetPolicy — the opaque failure this\n # preflight is meant to prevent.\n if err=$(aws iam get-policy --policy-arn \"$boundary_arn\" 2>&1 >/dev/null); then\n echo \"Permissions boundary: ${boundary_arn}\"\n return 0\n fi\n case \"$err\" in\n *NoSuchEntity*)\n echo \"ERROR: permissions boundary '${BOUNDARY_NAME}' not found (${boundary_arn}).\"\n echo \" This is the ONE-TIME IAM prerequisite — create it (and the\"\n echo \" MarketplaceMeteringDeployer role) before deploying. See the step-by-step\"\n echo \" bootstrap in references/iam-credentials.md -> 'Prerequisite setup (do this FIRST...)':\"\n echo \" aws iam create-policy --policy-name ${BOUNDARY_NAME} --policy-document file://boundary.json\"\n echo \" The deployer role's iam:CreateRole is gated on this exact boundary, so\"\n echo \" SAM role creation would otherwise fail with AccessDenied.\"\n exit 1\n ;;\n *AccessDenied*|*not\\ authorized*)\n echo \"WARN: cannot verify boundary '${boundary_arn}' (iam:GetPolicy denied); continuing.\"\n echo \" Grant iam:GetPolicy on the boundary ARN to enable this preflight, or\"\n echo \" ensure the boundary policy exists before deploying.\"\n return 0\n ;;\n *)\n echo \"ERROR: failed to verify boundary '${boundary_arn}': ${err}\"\n exit 1\n ;;\n esac\n}\n\n# ─── Deploy events stack (shared, us-east-1) ─────────────────────────────────\ndeploy_events_stack() {\n echo \"\"\n # The (stage-scoped) events stack is always deployed here. `sam deploy` is idempotent\n # (`--no-fail-on-empty-changeset` makes an unchanged stack a no-op change set), and a\n # requested MeteringMode / TestAccountAllowlist / EventSource change MUST be applied — so\n # there is deliberately NO existence-skip / SKIP_EVENTS flag (an existence-skip would\n # silently suppress those updates). The deploy role needs cloudformation:GetTemplateSummary\n # for the re-deploy of an existing stack.\n echo \"=== Deploying events stack ${EVENTS_STACK_NAME} to us-east-1 (shared across all products for this stage) ===\"\n verify_boundary_exists\n local ev_bucket\n ev_bucket=$(ensure_sam_bucket us-east-1)\n require_template template-events.yaml\n sam build --template-file template-events.yaml --region us-east-1\n sam deploy \\\n --template-file .aws-sam/build/template.yaml \\\n --stack-name \"$EVENTS_STACK_NAME\" \\\n --region us-east-1 \\\n --capabilities CAPABILITY_NAMED_IAM \\\n --s3-bucket \"$ev_bucket\" \\\n --no-confirm-changeset \\\n --no-fail-on-empty-changeset \\\n --parameter-overrides \"StackPrefix=$EVENTS_PREFIX\" \"PermissionsBoundaryName=${BOUNDARY_NAME}\" \"CreateDashboard=${CREATE_DASHBOARD:-true}\" \"MeteringMode=$METERING_MODE\" \"TestAccountAllowlist=$TEST_ACCOUNT_ALLOWLIST\" \"EventSource=$EVENT_SOURCE\"\n # NOTE: ProductCode is deliberately NOT passed to the SHARED events stack — the dry-run test\n # publisher takes productCode PER INVOCATION (payload), so a second product's deploy cannot\n # overwrite a shared per-product value (the events-stack ProductCode param stays empty).\n\n echo \"✅ Events stack deployed\"\n}\n\n# ─── Deploy main stack (per-product, seller's region) ─────────────────────────\ndeploy_main_stack() {\n local product_code=\"$1\"\n local region=\"$2\"\n local prefix=\"$3\"\n\n # Get subscribers table name from events stack\n local subscribers_table\n subscribers_table=$(aws cloudformation describe-stacks \\\n --stack-name \"$EVENTS_STACK_NAME\" \\\n --region us-east-1 \\\n --query 'Stacks[0].Outputs[?OutputKey==`SubscribersTableName`].OutputValue' \\\n --output text)\n\n if [ -z \"$subscribers_table\" ] || [ \"$subscribers_table\" = \"None\" ]; then\n echo \"ERROR: Could not get SubscribersTableName from events stack.\"\n echo \" Deploy the events stack first: ./deploy.sh events-only\"\n exit 1\n fi\n echo \"Subscribers table: $subscribers_table\"\n\n echo \"\"\n echo \"=== Deploying main stack to $region ===\"\n verify_boundary_exists\n # read-only recovery preflight — detect retained/orphaned resources that\n # would collide on retry and print guidance (never auto-deletes).\n warn_retained_resources \"$prefix\" \"$region\"\n # ensure the per-region API Gateway CloudWatch Logs account role/setting\n # exists before deploying the logging-enabled stage (multi-region correctness).\n ensure_apigw_cw_role \"$region\"\n local main_bucket\n main_bucket=$(ensure_sam_bucket \"$region\")\n require_template template.yaml\n sam build --template-file template.yaml --region \"$region\"\n # Pass overrides as discrete, individually-quoted Key=Value tokens (array form) so a\n # value containing spaces cannot inject additional overrides. Values are\n # already validated by validate_inputs().\n local overrides=(\n \"ProductCode=$product_code\"\n \"StackPrefix=$prefix\"\n \"SubscribersTableName=$subscribers_table\"\n \"PermissionsBoundaryName=$BOUNDARY_NAME\"\n \"StageName=$STAGE_NAME\"\n \"MeteringLockHours=$METERING_LOCK_HOURS\"\n \"UsageTableTtlDays=$USAGE_TABLE_TTL_DAYS\"\n \"AggregatedUsageTableTtlDays=$AGGREGATED_USAGE_TTL_DAYS\"\n \"CreateDashboard=$CREATE_DASHBOARD\"\n \"DeploymentMode=$DEPLOYMENT_MODE\"\n \"MeteringMode=$METERING_MODE\"\n \"TestAccountAllowlist=$TEST_ACCOUNT_ALLOWLIST\"\n)\n sam deploy \\\n --stack-name \"${prefix}-metering\" \\\n --region \"$region\" \\\n --capabilities CAPABILITY_NAMED_IAM \\\n --s3-bucket \"$main_bucket\" \\\n --no-confirm-changeset \\\n --no-fail-on-empty-changeset \\\n --parameter-overrides \"${overrides[@]}\"\n\n echo \"✅ Main stack deployed\"\n echo \"\"\n echo \"Stack outputs:\"\n aws cloudformation describe-stacks \\\n --stack-name \"${prefix}-metering\" \\\n --region \"$region\" \\\n --query 'Stacks[0].Outputs[*].[OutputKey,OutputValue]' \\\n --output table\n}\n\n# ─── Main ────────────────────────────────────────────────────────────────────\ncheck_prerequisites\n\necho \"=== Verifying AWS credentials ===\"\nACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text 2>/dev/null) || {\n echo \"ERROR: AWS credentials not configured. Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or run 'aws configure'.\"\n exit 1\n}\necho \"Account: $ACCOUNT_ID\"\n\n# CreateDashboard: whether to create the CloudWatch health dashboards (default true). Set\n# CREATE_DASHBOARD=false to skip; sellers can also delete/customize post-deploy. Sourced\n# before the events-only branch so both stacks honor it.\nCREATE_DASHBOARD=\"${CREATE_DASHBOARD:-true}\"\nif [ \"$CREATE_DASHBOARD\" != \"true\" ] && [ \"$CREATE_DASHBOARD\" != \"false\" ]; then\n echo \"ERROR: invalid CREATE_DASHBOARD '$CREATE_DASHBOARD' (must be 'true' or 'false').\"\n exit 1\nfi\n\n# DEPLOYMENT_MODE selects full (raw pipeline: discoverer/aggregator/cleanup) or direct-submit\n# (seller writes finalized records straight to aggregated_usage; only submitter + submission-\n# expiry are created). Direct-submit records MUST be final before insert (any record may be\n# submitted on the next 5-min run; a re-write returns DuplicateRecord).\nDEPLOYMENT_MODE=\"${DEPLOYMENT_MODE:-full}\"\nif [ \"$DEPLOYMENT_MODE\" != \"full\" ] && [ \"$DEPLOYMENT_MODE\" != \"direct-submit\" ]; then\n echo \"ERROR: invalid DEPLOYMENT_MODE '$DEPLOYMENT_MODE' (must be 'full' or 'direct-submit').\"\n exit 1\nfi\n\n# Handle events-only mode\nif [ \"${1:-}\" = \"events-only\" ]; then\n # Events stack is stage-scoped too — require STAGE_NAME here as well.\n STAGE_NAME=\"${STAGE_NAME:-}\"\n if [ -z \"$STAGE_NAME\" ] || ! [[ \"$STAGE_NAME\" =~ ^[A-Za-z0-9_-]+$ ]]; then\n echo \"ERROR: STAGE_NAME is required for events-only (e.g. STAGE_NAME=v1 ./deploy.sh events-only).\"\n exit 1\n fi\n EVENTS_PREFIX=\"awsmp-events-${STAGE_NAME}\"\n EVENTS_STACK_NAME=\"awsmp-events-${STAGE_NAME}-stack\"\n METERING_MODE=\"${METERING_MODE:-live}\"\n if [ \"$METERING_MODE\" != \"live\" ] && [ \"$METERING_MODE\" != \"dry-run\" ]; then\n echo \"ERROR: invalid METERING_MODE '$METERING_MODE' (allowed: live | dry-run).\"; exit 1\n fi\n TEST_ACCOUNT_ALLOWLIST=\"${TEST_ACCOUNT_ALLOWLIST:-}\"\n if [ \"$METERING_MODE\" = \"dry-run\" ]; then\n if [ -z \"$TEST_ACCOUNT_ALLOWLIST\" ]; then\n echo \"ERROR: METERING_MODE=dry-run requires TEST_ACCOUNT_ALLOWLIST (test buyer account ids).\"; exit 1\n fi\n if ! [[ \"$TEST_ACCOUNT_ALLOWLIST\" =~ ^[0-9]{12}(,[0-9]{12})*$ ]]; then\n echo \"ERROR: TEST_ACCOUNT_ALLOWLIST must be comma-separated 12-digit AWS account ids.\"; exit 1\n fi\n fi\n if [ \"$METERING_MODE\" = \"live\" ] && [ -n \"$TEST_ACCOUNT_ALLOWLIST\" ]; then\n echo \"ERROR: TEST_ACCOUNT_ALLOWLIST must be EMPTY for METERING_MODE=live.\"; exit 1\n fi\n if [ \"$METERING_MODE\" = \"dry-run\" ]; then\n EVENT_SOURCE=\"${STAGE_NAME}.agreement-marketplace\"\n case \"$EVENT_SOURCE\" in\n aws.*) echo \"ERROR: dry-run EVENT_SOURCE must not start with 'aws.' (STAGE_NAME='$STAGE_NAME' collides with the reserved production source).\"; exit 1 ;;\n esac\n else\n EVENT_SOURCE=\"aws.agreement-marketplace\"\n fi\n PRODUCT_CODE=\"${PRODUCT_CODE:-}\"\n deploy_events_stack\n echo \"\"\n echo \"Next: Deploy main stack per-product:\"\n echo \" STAGE_NAME=$STAGE_NAME ./deploy.sh \u003cPRODUCT_CODE> \u003cREGION>\"\n exit 0\nfi\n\n# Full deployment mode\nPRODUCT_CODE=\"${1:?Usage: ./deploy.sh events-only | ./deploy.sh \u003cPRODUCT_CODE> \u003cREGION> [PREFIX]}\"\nREGION=\"${2:?Usage: ./deploy.sh \u003cPRODUCT_CODE> \u003cREGION> [PREFIX]}\"\n\n# StageName is a MANDATORY seller-supplied answer — there is no default and no fallback\n# to `prod`. Supply it via the STAGE_NAME environment variable. Deployment fails fast if\n# it is empty so we never create an API with an empty/placeholder stage.\nSTAGE_NAME=\"${STAGE_NAME:-}\"\nif [ -z \"$STAGE_NAME\" ]; then\n echo \"ERROR: STAGE_NAME is required and has no default.\"\n echo \" Set the API Gateway stage name you want (your choice — e.g. v1, live, or prod).\"\n echo \" Example: STAGE_NAME=v1 ./deploy.sh $PRODUCT_CODE $REGION\"\n exit 1\nfi\nif ! [[ \"$STAGE_NAME\" =~ ^[A-Za-z0-9_-]+$ ]]; then\n echo \"ERROR: invalid STAGE_NAME '$STAGE_NAME' (allowed: A-Z a-z 0-9 _ -).\"\n exit 1\nfi\n\n# Stage-scoped names: the stage folds into BOTH stacks so multiple stages coexist without\n# collision. Main stack: awsmp-\u003cproductCode>-\u003cstage>-metering (prefix awsmp-\u003cproductCode>-\u003cstage>).\n# Events stack: awsmp-events-\u003cstage>-stack (prefix awsmp-events-\u003cstage>).\nPREFIX=\"${3:-awsmp-${PRODUCT_CODE}-${STAGE_NAME}}\"\nEVENTS_PREFIX=\"awsmp-events-${STAGE_NAME}\"\nEVENTS_STACK_NAME=\"awsmp-events-${STAGE_NAME}-stack\"\n\n# MeteringMode decides prod (live) vs non-prod (dry-run). live = real BatchMeterUsage;\n# dry-run = full pipeline but NO real submit (a sandbox). Prod vs non-prod is THIS switch,\n# not the free-form STAGE_NAME.\nMETERING_MODE=\"${METERING_MODE:-live}\"\nif [ \"$METERING_MODE\" != \"live\" ] && [ \"$METERING_MODE\" != \"dry-run\" ]; then\n echo \"ERROR: invalid METERING_MODE '$METERING_MODE' (allowed: live | dry-run).\"\n exit 1\nfi\n# For a non-prod (dry-run) stage a TestAccountAllowlist is REQUIRED and must be test accounts\n# ONLY; a live stage must NOT set it. TEST_ACCOUNT_ALLOWLIST is comma-separated 12-digit ids.\nTEST_ACCOUNT_ALLOWLIST=\"${TEST_ACCOUNT_ALLOWLIST:-}\"\nif [ \"$METERING_MODE\" = \"dry-run\" ]; then\n if [ -z \"$TEST_ACCOUNT_ALLOWLIST\" ]; then\n echo \"ERROR: METERING_MODE=dry-run requires TEST_ACCOUNT_ALLOWLIST (comma-separated TEST\"\n echo \" buyer account ids). A non-prod stage must use test accounts ONLY.\"\n exit 1\n fi\n if ! [[ \"$TEST_ACCOUNT_ALLOWLIST\" =~ ^[0-9]{12}(,[0-9]{12})*$ ]]; then\n echo \"ERROR: TEST_ACCOUNT_ALLOWLIST must be comma-separated 12-digit AWS account ids.\"\n exit 1\n fi\n echo \"NOTE: dry-run (non-prod) stage — the submitter will NOT call BatchMeterUsage (no real\"\n echo \" billing); use TEST accounts ONLY: $TEST_ACCOUNT_ALLOWLIST\"\nelse\n if [ -n \"$TEST_ACCOUNT_ALLOWLIST\" ]; then\n echo \"ERROR: TEST_ACCOUNT_ALLOWLIST must be EMPTY for METERING_MODE=live (production).\"\n exit 1\n fi\nfi\n# EventSource the events rule matches. dry-run uses a STAGE-SCOPED source (never the reserved\n# aws.* prefix) so the test publisher's events are delivered AND are disjoint from real prod\n# events (no same-account cross-match). live uses the real aws.agreement-marketplace.\nif [ \"$METERING_MODE\" = \"dry-run\" ]; then\n EVENT_SOURCE=\"${STAGE_NAME}.agreement-marketplace\"\n case \"$EVENT_SOURCE\" in\n aws.*)\n echo \"ERROR: dry-run EVENT_SOURCE must not start with 'aws.' (STAGE_NAME='$STAGE_NAME'\"\n echo \" collides with the reserved production source aws.agreement-marketplace).\"\n exit 1 ;;\n esac\nelse\n EVENT_SOURCE=\"aws.agreement-marketplace\"\nfi\n\n# MeteringLockHours: how many hours a metering hour stays open for late usage\n# before it is aggregated/submitted. Seller-configured via the METERING_LOCK_HOURS env var;\n# default 1 (submit a fully-complete hour). Validated 1..20 so an hour is first submitted\n# with >=4h of margin inside the 24h billable window (>=3 retries + rejection re-drive).\nMETERING_LOCK_HOURS=\"${METERING_LOCK_HOURS:-1}\"\nif ! [[ \"$METERING_LOCK_HOURS\" =~ ^[0-9]+$ ]] || [ \"$METERING_LOCK_HOURS\" -lt 1 ] || [ \"$METERING_LOCK_HOURS\" -gt 20 ]; then\n echo \"ERROR: invalid METERING_LOCK_HOURS '$METERING_LOCK_HOURS' (must be an integer 1..20).\"\n echo \" This is how many hours an hour stays open for late usage before submission.\"\n exit 1\nfi\n\n# TTL retention (days) for the RAW usage table (high-volume; RECOMMENDED on to control\n# cost). 0 disables. Floor of 2 days so a row can never expire before it is metered\n# (must exceed MeteringLockHours + the 24h billable window). The seller's WRITER sets the\n# `ttl` attribute; enabling it here only makes DynamoDB honor it.\nUSAGE_TABLE_TTL_DAYS=\"${USAGE_TABLE_TTL_DAYS:-365}\"\nif ! [[ \"$USAGE_TABLE_TTL_DAYS\" =~ ^[0-9]+$ ]] || { [ \"$USAGE_TABLE_TTL_DAYS\" -ne 0 ] && [ \"$USAGE_TABLE_TTL_DAYS\" -lt 2 ]; } || [ \"$USAGE_TABLE_TTL_DAYS\" -gt 3650 ]; then\n echo \"ERROR: invalid USAGE_TABLE_TTL_DAYS '$USAGE_TABLE_TTL_DAYS' (0 to disable, else an integer 2..3650).\"\n exit 1\nfi\n\n# TTL retention (days) for the AGGREGATED usage table (small billing audit trail).\n# DEFAULTS 0 (disabled = retain), which is recommended. When >0 the submitter sets `ttl`\n# on finalized Success rows only.\nAGGREGATED_USAGE_TTL_DAYS=\"${AGGREGATED_USAGE_TTL_DAYS:-0}\"\nif ! [[ \"$AGGREGATED_USAGE_TTL_DAYS\" =~ ^[0-9]+$ ]] || [ \"$AGGREGATED_USAGE_TTL_DAYS\" -gt 3650 ]; then\n echo \"ERROR: invalid AGGREGATED_USAGE_TTL_DAYS '$AGGREGATED_USAGE_TTL_DAYS' (0 to disable, else an integer 1..3650).\"\n exit 1\nfi\n\n# Validate all caller-supplied inputs before any AWS call.\nvalidate_inputs \"$PRODUCT_CODE\" \"$REGION\" \"$PREFIX\"\n\necho \"Product code: $PRODUCT_CODE\"\necho \"Region: $REGION\"\necho \"Stack prefix: $PREFIX\"\necho \"Stage name: $STAGE_NAME\"\n\ndeploy_events_stack\ndeploy_main_stack \"$PRODUCT_CODE\" \"$REGION\" \"$PREFIX\"\n\necho \"\"\necho \"=== Deployment complete ===\"\necho \"\"\necho \"Next steps:\"\necho \"1. Copy the RegistrationUrl from stack outputs above\"\necho \"2. Set it as the fulfillment URL in AWS Marketplace Management Portal\"\necho \"3. Subscribe to the product and click 'Set up your account' to test\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":27887,"content_sha256":"4bc8ddc60d3ecb16cdd1f83e343b773c54e46967d22719b73ae73c9ffaebad3a"},{"filename":"scripts/deprovision_cleanup.py","content":"\"\"\"Deprovision cleanup Lambda (events stack, us-east-1).\n\nFired by an EventBridge Scheduler rate(15 minutes). Finalizes licenses whose ~1-hour\npost-deprovision flush window has closed: it Queries the sparse deprovisioning-pending-index\nfor entries whose ``deprovisioningExpiry \u003c= now`` and, for each, sets\n``subscriptionStatus = inactive`` and REMOVEs both deprovisioning markers\n(``deprovisioningPendingFlag`` + ``deprovisioningExpiry``) so the row drops out of the index.\n\nWhy a time-based cleanup owns finalization (not the submitter):\n * A deprovisioning license may have usage across MULTIPLE hours and MULTIPLE regions\n (one submitter per metering region, one shared subscribers table in us-east-1). No single\n submitter run can know the license is fully drained, so a submitter-driven flip to\n ``inactive`` would fire prematurely on the first region/hour submitted.\n * The flush window is absolute (~1h after the deprovision event). Once it has elapsed, no\n region can meter the license anymore (BatchMeterUsage returns CustomerNotSubscribed), so a\n single time-based finalize in us-east-1 is correct for all regions at once — no cross-region\n coordination.\n\nIdempotent: the finalize UpdateItem is conditioned on the marker still being present, so\nconcurrent/duplicate runs do not clobber a row that was already finalized or re-deprovisioned.\n\"\"\"\n\nimport logging\nimport os\nfrom datetime import datetime, timezone\n\nimport boto3\n\nlogger = logging.getLogger()\nlogger.setLevel(logging.INFO)\n\ndynamodb = boto3.resource(\"dynamodb\")\nsubscribers_table = dynamodb.Table(os.environ[\"SUBSCRIBERS_TABLE\"])\n\n\ndef _mask(value):\n \"\"\"Mask a sensitive identifier for logging: keep only the last 4 chars. Buyer account IDs /\n license ARNs are sensitive and MUST NOT be logged in full (mirrors register.py._mask).\"\"\"\n if not value:\n return \"\u003cnone>\"\n s = str(value)\n return \"****\" + s[-4:] if len(s) > 4 else \"****\"\n\nDEPROVISIONING_PENDING_INDEX = \"deprovisioning-pending-index\"\nDEPROVISIONING_PENDING_VALUE = \"1\" # constant HASH of the sparse index\n\n\ndef handler(event, context):\n \"\"\"Finalize every deprovisioning license whose flush window has elapsed.\"\"\"\n now_iso = datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n finalized = 0\n kwargs = {\n \"IndexName\": DEPROVISIONING_PENDING_INDEX,\n # flag = constant HASH, deprovisioningExpiry = RANGE: everything already expired.\n \"KeyConditionExpression\": (\n \"deprovisioningPendingFlag = :f AND deprovisioningExpiry \u003c= :now\"\n ),\n \"ExpressionAttributeValues\": {\":f\": DEPROVISIONING_PENDING_VALUE, \":now\": now_iso},\n }\n while True:\n response = subscribers_table.query(**kwargs)\n for item in response.get(\"Items\", []):\n finalized += _finalize(item)\n if \"LastEvaluatedKey\" not in response:\n break\n kwargs[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n\n logger.info(f\"Deprovision cleanup: finalized {finalized} expired license(s) as inactive\")\n return {\"finalized\": finalized}\n\n\ndef _finalize(item):\n \"\"\"Set subscriptionStatus=inactive and REMOVE both deprovisioning markers, idempotently.\"\"\"\n license_arn = item.get(\"licenseArn\", \"\")\n account_id = item.get(\"customerAWSAccountId\", \"\")\n if not license_arn or not account_id:\n return 0\n try:\n from datetime import datetime, timezone\n\n now = datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n subscribers_table.update_item(\n Key={\"licenseArn\": license_arn, \"customerAWSAccountId\": account_id},\n UpdateExpression=(\n # updatedAt goes in the SET clause (row already exists, so createdAt is kept);\n # REMOVE follows the full SET clause per DynamoDB expression syntax.\n \"SET subscriptionStatus = :inactive, updatedAt = :now \"\n \"REMOVE deprovisioningPendingFlag, deprovisioningExpiry\"\n ),\n # Only finalize while the marker is still present — makes concurrent/duplicate\n # runs (and a row re-deprovisioned in the meantime) safe.\n ConditionExpression=\"attribute_exists(deprovisioningPendingFlag)\",\n ExpressionAttributeValues={\":inactive\": \"inactive\", \":now\": now},\n )\n logger.info(\n f\"Finalized deprovisioning->inactive for licenseArn={_mask(license_arn)} (window elapsed)\"\n )\n return 1\n except dynamodb.meta.client.exceptions.ConditionalCheckFailedException:\n # Already finalized by a concurrent run (or re-deprovisioned) — nothing to do.\n return 0\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4643,"content_sha256":"b34852a90ffd42b29f1bd67e3ed6f81cdcf6082c63e3e6623145f99be0c17ead"},{"filename":"scripts/discoverer.py","content":"\"\"\"Discoverer Lambda.\n\nInvoked hourly via 6 EventBridge rules x 5 constant-Input targets = 30 invocations on ONE\nfunction (ReservedConcurrentExecutions=30). Each invocation owns exactly one slice,\ndetermined SOLELY by its constant target Input (never a runtime clock read):\n\n - {\"mode\": \"meter\", \"hourOffset\": N} for N in 1..23 -> meter one completed hour\n - {\"mode\": \"ageout\", \"shard\": M} for M in 0..6 -> age out one lookback shard\n\nmeter mode:\n Query the metering_pending GSI for the single hour bucket floor(now,hour)-hourOffset and\n enqueue ONE work message per (licenseArn, customerAWSAccountId, dimension, hourBucket)\n group to the work queue. It does NOT read group rows, aggregate, submit, or write back.\n The lock gate is MONTH-AWARE: current-month buckets use the configured lock; a\n previous-month bucket drops to lock 0 (immediate) from 03:00 UTC on the 1st until the\n 06:00 UTC month-end service cutoff, so previous-month reconciliation flushes in time.\n\nageout mode:\n Enumerate lookback buckets older than 24h whose (hours_back % 7) == shard, and for each\n stale pending row perform the terminal age-out directly: REMOVE meteringPending, set\n meteringStatus=AggregationExpired, emit the UsageAggregationExpired metric. Age-out is a cheap UpdateItem that\n does not need the aggregation pipeline. Previous-month buckets are NOT aged out while the\n month-end grace is open (on the 1st before 06:00 UTC) — the service still accepts them.\n\"\"\"\n\nimport json\nimport os\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any, Dict, List\n\nimport boto3\n\nfrom handlers import metering_core as core\n\nlogger = core.logger\n\nsqs = boto3.client(\"sqs\")\nWORK_QUEUE_URL = os.environ.get(\"WORK_QUEUE_URL\", \"\")\n# Deprovisioning flush enqueues to a DEDICATED work queue (its own aggregator ESM) so a\n# deprovisioning backlog is isolated from — never queued behind — the regular hourly backlog.\nDEPROVISION_WORK_QUEUE_URL = os.environ.get(\"DEPROVISION_WORK_QUEUE_URL\", \"\")\n\n# For a deprovisioning license, also flush its CURRENT (in-progress) hour once we are within\n# this lead time of the license's deprovisioningExpiry (the ~1h window close) — leaving ~10 min\n# for the pipeline (sweep -> aggregator -> rate(5m) submitter) to drain the current hour before\n# the window closes, while reserving the current hour until then for the seller's final writes.\nCURRENT_HOUR_LEAD = timedelta(minutes=10)\n\n\ndef _parse_iso(ts):\n \"\"\"Parse an ISO-8601 UTC timestamp (e.g. '2026-09-16T15:00:00Z') to an aware datetime,\n or None if absent/unparseable (caller then conservatively does NOT flush the current hour).\"\"\"\n if not ts:\n return None\n try:\n dt = datetime.fromisoformat(str(ts).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return None\n return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)\n\n# Seller-configured lock period: an hour is not aggregated/submitted until it has\n# been closed for MeteringLockHours hours. meter-mode invocations whose hourOffset is below\n# the lock no-op, giving the seller's writers that many hours to send late usage for the\n# hour before it is locked and submitted. Default 1 (process a fully-complete hour).\nMETERING_LOCK_HOURS = int(os.environ.get(\"METERING_LOCK_HOURS\", \"1\"))\n\n# Age-out lookback bounds (mirror the previous meter_usage age-out sweep, sharded by 7).\nAGEOUT_EMPTY_STREAK_STOP = 6\nAGEOUT_MAX_HOURS_BACK = 24 * 400\nAGEOUT_SHARDS = 7\n\n\ndef handler(event, context):\n mode = (event or {}).get(\"mode\")\n if mode == \"meter\":\n return _handle_meter(int(event[\"hourOffset\"]))\n if mode == \"ageout\":\n return _handle_ageout(int(event[\"shard\"]))\n if mode == \"flush-deprovisioning\":\n return _handle_flush_deprovisioning()\n raise RuntimeError(f\"Discoverer invoked without a valid mode/offset: {event!r}\")\n\n\ndef _handle_meter(hour_offset: int):\n if not (1 \u003c= hour_offset \u003c= 23):\n raise RuntimeError(f\"hourOffset out of range: {hour_offset}\")\n if not WORK_QUEUE_URL:\n raise RuntimeError(\"WORK_QUEUE_URL not configured\")\n\n now = datetime.now(timezone.utc)\n hour_bucket = core.hour_bucket_for_offset(now, hour_offset)\n\n # Month-aware lock: current-month buckets use the configured lock; a\n # previous-month bucket drops to lock 0 (immediate) from 03:00 UTC on the 1st until the\n # 06:00 UTC service cutoff, so month-end reconciliation flushes before the grace closes.\n lock = core.effective_lock_hours(hour_bucket, now, METERING_LOCK_HOURS)\n if hour_offset \u003c lock:\n logger.info(\n f\"hourOffset {hour_offset} \u003c effective lock {lock} for bucket {hour_bucket}: \"\n \"still open for late usage; no-op\"\n )\n return {\"hourOffset\": hour_offset, \"lock\": lock, \"enqueued\": 0, \"noop\": True}\n\n groups = core.get_pending_groups(hour_bucket)\n if not groups:\n logger.info(f\"No pending usage for hour bucket {hour_bucket} (offset {hour_offset})\")\n return {\"hourBucket\": hour_bucket, \"enqueued\": 0}\n\n enqueued = _enqueue_work(groups, hour_bucket)\n logger.info(f\"Enqueued {enqueued} group(s) for hour bucket {hour_bucket}\")\n return {\"hourBucket\": hour_bucket, \"enqueued\": enqueued}\n\n\ndef _handle_flush_deprovisioning():\n \"\"\"Expedited flush for licenses in their ~1-hour deprovisioning window.\n\n Runs on a rate(5m) rule (independent of the ordinary hourly meter rules, which are\n UNCHANGED). Queries the sparse deprovisioning-pending-index for licenses the subscription\n Lambda marked on `License Deprovisioned`, then enqueues each such license's pending groups\n over now-23h .. now-1h to the DEDICATED deprovision-work queue (isolated from the regular\n backlog), IGNORING MeteringLockHours so the aggregator + 5-min submitter can flush them\n before the window closes. The CURRENT (in-progress) hour is normally RESERVED (left for the\n seller's own direct writes/final events), BUT is ALSO flushed for a license once we are\n within CURRENT_HOUR_LEAD (10 min) of that license's deprovisioningExpiry (the ~1h window\n close) — so the current-hour usage still traverses the pipeline before the window closes.\n Idempotent: the aggregator conditional-put + BatchMeterUsage first-write-wins dedup prevent\n a double-bill if the ordinary run later re-enqueues the same group.\n \"\"\"\n if not DEPROVISION_WORK_QUEUE_URL:\n raise RuntimeError(\"DEPROVISION_WORK_QUEUE_URL not configured\")\n\n # {licenseArn: deprovisioningExpiry} — expiry (= deprovision event time + ~1h, the window\n # close) gates whether the CURRENT hour should now be flushed (see CURRENT_HOUR_LEAD below).\n deprovisioning = core.deprovisioning_licenses_with_expiry()\n if not deprovisioning:\n logger.info(\"flush-deprovisioning: no licenses in a deprovisioning window\")\n return {\"deprovisioningLicenses\": 0, \"enqueued\": 0}\n\n now = datetime.now(timezone.utc)\n\n # Current-hour flush is gated on the window close: for each deprovisioning license, also\n # sweep the CURRENT (in-progress) hour ONCE we are within CURRENT_HOUR_LEAD (10 min) of the\n # license's deprovisioningExpiry, so the current-hour usage still traverses discoverer ->\n # aggregator -> submitter before the ~1-hour window closes, while leaving the current hour\n # reserved until then for the seller's final writes. Flush when now >= expiry - 10min.\n current_hour_licenses = set()\n for license_arn, expiry in deprovisioning.items():\n exp = _parse_iso(expiry)\n if exp is not None and now >= exp - CURRENT_HOUR_LEAD:\n current_hour_licenses.add(license_arn)\n\n total_enqueued = 0\n # offset 0 = current hour (only for settled licenses); offsets 1..23 = completed hours (all).\n for hour_offset in range(0, 24):\n hour_bucket = core.hour_bucket_for_offset(now, hour_offset)\n licenses_for_bucket = (\n current_hour_licenses if hour_offset == 0 else deprovisioning.keys()\n )\n if not licenses_for_bucket:\n continue\n # TARGETED read: query the metering_pending GSI per deprovisioning license\n # (meteringPending=:hb AND licenseArn=:la) rather than reading the whole bucket\n # partition across ALL licenses and filtering in memory — O(deprovisioning-groups),\n # never a full-partition scan. Lock is deliberately NOT consulted here —\n # that is the whole point of expediting.\n groups = []\n for license_arn in licenses_for_bucket:\n groups.extend(core.get_pending_groups_for_license(hour_bucket, license_arn))\n if groups:\n total_enqueued += _enqueue_work(groups, hour_bucket, DEPROVISION_WORK_QUEUE_URL)\n logger.info(\n f\"flush-deprovisioning: enqueued {total_enqueued} group(s) across \"\n f\"{len(deprovisioning)} deprovisioning license(s) \"\n f\"({len(current_hour_licenses)} also current-hour-flushed) (lock bypassed)\"\n )\n return {\"deprovisioningLicenses\": len(deprovisioning), \"enqueued\": total_enqueued}\n\n\ndef _enqueue_work(groups, hour_bucket, queue_url=None) -> int:\n \"\"\"SendMessageBatch one message per (licenseArn, account, dimension, hourBucket) group.\n ``queue_url`` defaults to the regular work queue; the deprovisioning flush passes the\n dedicated deprovision-work queue so its backlog is isolated from the regular one.\"\"\"\n queue_url = queue_url or WORK_QUEUE_URL\n enqueued = 0\n batch: List[Dict[str, Any]] = []\n for license_arn, account_id, dimension in groups:\n body = {\n \"licenseArn\": license_arn,\n \"customerAWSAccountId\": account_id,\n \"dimension\": dimension,\n \"hourBucket\": hour_bucket,\n }\n batch.append(\n {\"Id\": str(len(batch)), \"MessageBody\": json.dumps(body)}\n )\n if len(batch) == 10: # SQS SendMessageBatch max 10\n _flush_batch(batch, queue_url)\n enqueued += len(batch)\n batch = []\n if batch:\n _flush_batch(batch, queue_url)\n enqueued += len(batch)\n return enqueued\n\n\ndef _flush_batch(batch, queue_url):\n resp = sqs.send_message_batch(QueueUrl=queue_url, Entries=batch)\n failed = resp.get(\"Failed\", [])\n if failed:\n # Do not swallow: a failed enqueue means those groups would silently not be\n # metered. Raise so the invocation fails and the alarm fires; the rows keep\n # meteringPending and are re-discovered next hour.\n raise RuntimeError(f\"SQS SendMessageBatch had {len(failed)} failure(s): {failed}\")\n\n\ndef _handle_ageout(shard: int):\n \"\"\"Age out pending rows > 24h old whose (hours_back % 7) == shard.\"\"\"\n if not (0 \u003c= shard \u003c AGEOUT_SHARDS):\n raise RuntimeError(f\"shard out of range: {shard}\")\n now = datetime.now(timezone.utc)\n base = now.replace(minute=0, second=0, microsecond=0)\n from datetime import timedelta\n\n hours_back = 24\n empty_streak = 0\n aged = 0\n while hours_back \u003c= AGEOUT_MAX_HOURS_BACK and empty_streak \u003c AGEOUT_EMPTY_STREAK_STOP:\n if hours_back % AGEOUT_SHARDS != shard:\n hours_back += 1\n continue\n hour_bucket = (base - timedelta(hours=hours_back)).strftime(core.HOUR_FMT)\n hours_back += 1\n # do NOT age out a PREVIOUS-month bucket while the month-end grace is open\n # (on the 1st before 06:00 UTC) — the service still accepts it, and age-out here\n # would expire exactly the records the reconciliation window is meant to save.\n if core.previous_month_grace_open(now) and core.is_previous_month_bucket(hour_bucket, now):\n continue\n pending = core.get_pending_groups(hour_bucket)\n if not pending:\n empty_streak += 1\n continue\n empty_streak = 0\n aged += _expire_bucket(pending, hour_bucket)\n logger.info(f\"Age-out shard {shard}: expired {aged} row(s)\")\n return {\"shard\": shard, \"expired\": aged}\n\n\ndef _expire_bucket(pending, hour_bucket) -> int:\n aged = 0\n for license_arn, account_id, dimension in pending:\n for row in core.read_group_rows(license_arn, account_id, dimension, hour_bucket):\n if \"meteringPending\" not in row:\n continue\n sort_key = row.get(\"customerAWSAccountId_dimension_timestamp\", \"\")\n logger.warning(\n \"AGE-OUT: dropping pending usage older than 24h (unbillable) \"\n f\"license={core.mask(license_arn)} account={core.mask(account_id)} \"\n f\"dim={dimension} hour={hour_bucket}\"\n )\n core.emf_metric(\"UsageAggregationExpired\", 1)\n _expr, _vals, _ = core.with_audit_timestamps(\n \"REMOVE meteringPending SET meteringStatus = :st\",\n {\":st\": \"AggregationExpired\"},\n )\n core.usage_table.update_item(\n Key={\n \"licenseArn\": license_arn,\n \"customerAWSAccountId_dimension_timestamp\": sort_key,\n },\n UpdateExpression=_expr,\n ExpressionAttributeValues=_vals,\n )\n aged += 1\n return aged\n","content_type":"text/x-python; charset=utf-8","language":"python","size":13136,"content_sha256":"5b6597711b6a5462e4aceb9b9c11cbdaa4c24f23a99e036d4710a14aac559555"},{"filename":"scripts/expiry.py","content":"\"\"\"Aggregated-usage expiry Lambda.\n\nEventBridge-scheduled on the SAME rate(5 minutes) rule as the submitter (independent\ntarget, ReservedConcurrentExecutions=1). It sweeps the ``aggregated_usage`` table and\nterminally expires pending records that can no longer be metered, so a record that was\nnever submitted (e.g. after a prolonged submitter backlog, or a previous-month record once\nthe month-end grace closes) is never left pending forever and the loss is always alarmed\n(never silent). It operates on ``aggregated_usage`` ONLY — the raw usage table's own\nage-out is handled by the discoverer's ageout shards.\n\nExpiry rules (a pending aggregated record is expired when):\n - its hour is more than 24h in the past (past the BatchMeterUsage billable window); OR\n - it is a PREVIOUS-month record and the month-end grace has closed — i.e. it is on/after\n 06:00 UTC on the 1st of the month. Before 06:00 UTC on the 1st the previous-month grace\n is still open, so previous-month records are still submittable and are NOT expired.\n\nCase (b) INCLUDES previous-month records still INSIDE the 24h window: once the grace has\nclosed the service rejects them, so the submitter (which reads the now-23h..now-1h window)\nwould otherwise re-attempt them every run until they cross 24h. The expiry sweep therefore\nalso covers the in-window range (1..23) for previous-month buckets when the grace is closed,\nand its past-window sweep starts at hours_back == 24 so it is CONTIGUOUS with the submitter's\nwindow (no bucket falls between the two). Current-month in-window buckets are still\nsubmittable and are never expired.\n\nExpiring = REMOVE meteringPending, SET meteringStatus=SubmissionExpired, emit the\nUsageSubmissionExpired metric (distinct from the raw table's UsageAggregationExpired age-out,\nso an operator can tell \"aggregated but never submitted\" from \"aged out before aggregation\").\nThe record itself is retained (audit).\n\"\"\"\n\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any, Dict\n\nfrom handlers import metering_core as core\n\nlogger = core.logger\n\n# How far back to sweep for stale pending buckets, and the empty-streak early stop — a\n# sparse, self-terminating lookback (a backlog of non-empty buckets extends it).\nEXPIRY_MAX_HOURS_BACK = 24 * 400\nEXPIRY_EMPTY_STREAK_STOP = 6\n\n\ndef handler(event, context):\n if core.aggregated_usage_table is None:\n raise RuntimeError(\"AGGREGATED_USAGE_TABLE not configured\")\n\n now = datetime.now(timezone.utc)\n base = now.replace(minute=0, second=0, microsecond=0)\n grace_open = core.previous_month_grace_open(now) # 1st of month, before 06:00 UTC\n\n aged = 0\n\n # (b) Month-end reconciliation: once the grace has CLOSED (on/after 06:00 UTC on the\n # 1st), previous-month records INSIDE the 24h window are no longer submittable — the\n # service rejects them — yet the submitter's now-23h..now-1h window would keep\n # re-attempting them every run (failed submits + alarm noise) until they naturally\n # cross 24h. So when the grace is closed, expire previous-month buckets in the in-window\n # range 1..23 too. Current-month in-window buckets are still submittable → never touched.\n if not grace_open:\n for hours_back in range(1, 24):\n hour_bucket = (base - timedelta(hours=hours_back)).strftime(core.HOUR_FMT)\n if core.is_previous_month_bucket(hour_bucket, now):\n aged += _expire_bucket(hour_bucket)\n\n # Past-window sweep: everything strictly older than 24h is unbillable regardless of\n # month. Start at hours_back == 24 so this range is CONTIGUOUS with the submitter's\n # now-1h..now-23h window (no bucket falls between the two). A previous-month bucket while\n # the grace is still OPEN is skipped (still submittable). Sparse, self-terminating walk.\n hours_back = 24\n empty_streak = 0\n while hours_back \u003c= EXPIRY_MAX_HOURS_BACK and empty_streak \u003c EXPIRY_EMPTY_STREAK_STOP:\n hour_bucket = (base - timedelta(hours=hours_back)).strftime(core.HOUR_FMT)\n hours_back += 1\n\n is_prev_month = core.is_previous_month_bucket(hour_bucket, now)\n # A previous-month bucket is only expirable once the grace has CLOSED (>=06:00 UTC\n # on the 1st). While the grace is open, previous-month records are still submittable\n # → skip. Current-month buckets past 24h are always expirable.\n if is_prev_month and grace_open:\n continue\n\n found_any = _expire_bucket(hour_bucket)\n aged += found_any\n empty_streak = 0 if found_any else empty_streak + 1\n\n if aged:\n logger.error(f\"Expired {aged} aggregated record(s) past the submittable window\")\n else:\n logger.info(\"No aggregated records to expire\")\n return {\"expired\": aged}\n\n\ndef _expire_bucket(hour_bucket) -> int:\n expired = 0\n kwargs: Dict[str, Any] = {\n \"IndexName\": \"metering_pending\",\n \"KeyConditionExpression\": \"meteringPending = :hb\",\n \"ExpressionAttributeValues\": {\":hb\": hour_bucket},\n }\n while True:\n response = core.aggregated_usage_table.query(**kwargs)\n for item in response.get(\"Items\", []):\n core.emf_metric(\"UsageSubmissionExpired\", 1)\n _expr, _vals, _ = core.with_audit_timestamps(\n \"REMOVE meteringPending SET meteringStatus = :st\",\n {\":st\": \"SubmissionExpired\"},\n )\n core.aggregated_usage_table.update_item(\n Key={\n \"licenseArn\": item[\"licenseArn\"],\n \"account_dimension_hour\": item[\"account_dimension_hour\"],\n },\n UpdateExpression=_expr,\n ExpressionAttributeValues=_vals,\n )\n logger.warning(\n \"AGGREGATED EXPIRY: unbillable pending aggregated record \"\n f\"license={core.mask(item['licenseArn'])} hour={hour_bucket}\"\n )\n expired += 1\n if \"LastEvaluatedKey\" not in response:\n break\n kwargs[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n return expired\n","content_type":"text/x-python; charset=utf-8","language":"python","size":6107,"content_sha256":"ecdd658c9b71b0d8e9f26f62daadef77cccb5f90c3a26cfa8ecb1876061e9f6b"},{"filename":"scripts/metering_core.py","content":"\"\"\"Shared metering core for the decoupled metering pipeline.\n\nThis module holds the logic reused across the four pipeline Lambdas\n(``discoverer`` / ``aggregator`` / ``cleanup`` / ``submitter``): DynamoDB clients,\nthe ``metering_pending`` GSI discovery, the targeted per-group read, VMT allocation\nmerging, strict quantity/record validation, the BatchMeterUsage submit (with retry-once\nand request-level poison isolation), and the small helpers (masking, EMF metrics).\n\ndesign (see references/scalable-metering-design.md):\n - Usage-table sort key is ``customerAWSAccountId#dimension#timestampSeconds`` at SECOND\n precision (revises the earlier millisecond precision). Each second-row is treated as\n authoritative; the pipeline does NOT coalesce same-second events.\n - Aggregation, submission and raw-row cleanup are decoupled into separate Lambdas\n connected by SQS. ``aggregated_usage`` (PK licenseArn, SK account#dimension#hour) is\n the submission source of truth, written by the aggregator via a CONDITIONAL PutItem\n (the idempotency commit point) and consumed by the submitter.\n\"\"\"\n\nimport json\nimport logging\nimport math\nimport os\nimport re\nfrom datetime import datetime, timedelta, timezone\nfrom decimal import Decimal\nfrom typing import Any, Dict, List, Optional, Tuple\n\nimport boto3\nfrom botocore.exceptions import ClientError\n\nlogger = logging.getLogger()\nlogger.setLevel(logging.INFO)\n\n# Subscribers table is in us-east-1 (where EventBridge lifecycle events land).\n_dynamodb_iad = boto3.resource(\n \"dynamodb\", region_name=os.environ.get(\"SUBSCRIBERS_TABLE_REGION\", \"us-east-1\")\n)\nsubscribers_table = _dynamodb_iad.Table(os.environ[\"SUBSCRIBERS_TABLE\"])\n\n# Usage + aggregated-usage tables are in the seller's metering region (this Lambda's\n# runtime region).\n_dynamodb = boto3.resource(\"dynamodb\")\n# USAGE_TABLE is set on the raw-pipeline functions (discoverer/aggregator/cleanup). In\n# direct-submit mode there is no raw usage table, so the submitter/expiry (which import this\n# module) run without it — tolerate its absence rather than failing at import.\n_USAGE_TABLE_NAME = os.environ.get(\"USAGE_TABLE\")\n# Typed Any (not Optional[Table]) so mypy check_untyped_defs does not flag .query on the\n# raw-pipeline read helpers below: those helpers only run on the discoverer/aggregator/\n# cleanup Lambdas, where USAGE_TABLE is always set. No behavior change.\nusage_table: Any = _dynamodb.Table(_USAGE_TABLE_NAME) if _USAGE_TABLE_NAME else None\n# AGGREGATED_USAGE_TABLE is set on the aggregator/submitter/cleanup functions; the\n# discoverer does not need it, so tolerate its absence there.\n_AGG_TABLE_NAME = os.environ.get(\"AGGREGATED_USAGE_TABLE\")\naggregated_usage_table = _dynamodb.Table(_AGG_TABLE_NAME) if _AGG_TABLE_NAME else None\n\n# BatchMeterUsage is a REGIONAL API: no hardcoded region so it runs in the Lambda's region.\nmp_client = boto3.client(\"meteringmarketplace\")\n\nPRODUCT_CODE = os.environ[\"PRODUCT_CODE\"]\n\nBATCH_SIZE = 25 # BatchMeterUsage hard limit: max 25 UsageRecords per call.\nMAX_ALLOCATIONS_PER_RECORD = 2500 # BatchMeterUsage per-UsageRecord UsageAllocations cap.\nMAX_TAGS_PER_ALLOCATION = 5 # BatchMeterUsage per-allocation Tags cap.\nCLEANUP_KEYS_PER_MESSAGE = 100 # row keys per cleanup SQS message.\n\n_LICENSE_ARN_RE = re.compile(r\"^arn:aws[a-zA-Z-]*:license-manager:[^:]*:[0-9]{12}:license:.+$\")\n\nMETRIC_NAMESPACE = os.environ.get(\"METRIC_NAMESPACE\", \"AwsMarketplace/Metering\")\n# live (prod) vs dry-run (non-prod sandbox). Emitted as an EMF dimension so a dry run is never\n# mistaken for real billing on the dashboards/alarms.\nMETERING_MODE = os.environ.get(\"METERING_MODE\", \"live\").strip().lower()\n\nSTATUS_REJECTED = \"RejectedClientSide\"\n# Terminal status for a request-level (SERVER-SIDE) BatchMeterUsage exception isolated to one\n# aggregated_usage record (InvalidUsageDimensionException, InvalidLicenseException, ...). This is\n# NOT a client-side writer-contract violation (those are STATUS_REJECTED on the RAW table), so the\n# aggregated_usage record is stamped \"Error\" — the exception name is kept in meteringStatusReason.\nSTATUS_ERROR = \"Error\"\n\n# Max recursion depth for request-level-failure bisection. Bisecting isolates a poison record so\n# its co-batched records still meter, but UNBOUNDED bisection can issue up to ~2N BatchMeterUsage\n# calls for an N-record batch when many records fail — a real load/throttle risk on the submitter.\n# The cap is DERIVED from BATCH_SIZE as ceil(log2(BATCH_SIZE)) (= 5 for 25) so the recursion can\n# always narrow a full batch down to a SINGLE record — a shallower cap (e.g. 3) leaves the\n# smallest reachable sub-batch at ~4 records and can NEVER isolate a lone poison record, defeating\n# the purpose. At the cap a still-failing MULTI-record sub-batch is NOT split further — it is left\n# pending (no terminal outcome) so it is retried on the NEXT run and a BisectDepthExceeded metric\n# is emitted; a single isolated record is still terminally reason-coded.\nMAX_BISECT_DEPTH = math.ceil(math.log2(BATCH_SIZE)) if BATCH_SIZE > 1 else 0\n\n# Sparse deprovisioning index (subscribers table): the subscription Lambda sets\n# `deprovisioningPendingFlag = DEPROVISIONING_PENDING_VALUE` (constant HASH) + a\n# `deprovisioningExpiry` (event time + ~1h, ISO) on License Deprovisioned; the events-stack\n# cleanup Lambda REMOVEs both once expired. The index is HASH=flag + RANGE=expiry, so a\n# single Query returns every license still in a flush window (discoverer sweep + submitter),\n# and a range Query (expiry \u003c= now) returns the expired ones (cleanup).\nDEPROVISIONING_PENDING_INDEX = \"deprovisioning-pending-index\"\nDEPROVISIONING_PENDING_VALUE = \"1\"\n\n# Hour-bucket format (GSI HASH) and second-precision timestamp format (sort key).\nHOUR_FMT = \"%Y-%m-%dT%H\"\nSECOND_FMT = \"%Y-%m-%dT%H:%M:%S\" # raw-usage sort-key suffix precision (whole seconds)\n\n\ndef validate_sort_key(sort_key: str, account_id: str, dimension: str) -> Optional[str]:\n \"\"\"Validate a raw-usage row's sort key is exactly\n ``{customerAWSAccountId}#{dimension}#{YYYY-MM-DDTHH:MM:SS}`` (SECOND precision).\n\n Returns a client-side reject reason code if malformed, else None. This enforces the\n seller-writer contract at aggregation time (the aggregator's fold) so a wrong-precision\n (e.g. millisecond) or malformed suffix is caught as a reason-coded RejectedClientSide row\n rather than silently mis-folded or never metered. The account/dimension segments MUST\n match the group the row was read for (a mismatch means a corrupt/mis-keyed row).\n \"\"\"\n if not sort_key:\n return \"MalformedSortKey\"\n # rsplit on the last two '#': the timestamp suffix has no '#', but a dimension MAY.\n parts = sort_key.split(\"#\")\n if len(parts) \u003c 3:\n return \"MalformedSortKey\"\n key_account = parts[0]\n key_ts = parts[-1]\n key_dimension = \"#\".join(parts[1:-1])\n if key_account != account_id or key_dimension != dimension:\n return \"SortKeyMismatch\"\n # Exact SECOND precision: strptime accepts it AND re-formatting round-trips (so a\n # millisecond/fractional or truncated suffix like '...:07.123' or '...T13' is rejected).\n try:\n parsed = datetime.strptime(key_ts, SECOND_FMT)\n except (ValueError, TypeError):\n return \"MalformedTimestamp\"\n if parsed.strftime(SECOND_FMT) != key_ts:\n return \"MalformedTimestamp\"\n return None\n\n\n# ─── Time windows ────────────────────────────────────────────────────────────\ndef completed_hour_buckets(now: datetime) -> List[str]:\n \"\"\"Hour-bucket strings for now-23h .. now-1h inclusive, OLDEST first.\"\"\"\n base = now.replace(minute=0, second=0, microsecond=0)\n return [(base - timedelta(hours=h)).strftime(HOUR_FMT) for h in range(23, 0, -1)]\n\n\ndef hour_bucket_for_offset(now: datetime, hour_offset: int) -> str:\n \"\"\"The single completed hour bucket ``floor(now, hour) - hour_offset``.\n\n The discoverer derives its bucket purely from the constant target Input offset, never\n from a second wall-clock read, so 23 offsets map to 23 disjoint, gap-free buckets.\n \"\"\"\n base = now.replace(minute=0, second=0, microsecond=0)\n return (base - timedelta(hours=hour_offset)).strftime(HOUR_FMT)\n\n\n# ─── Audit timestamps (createdAt/updatedAt) ────────────────────────────────────\ndef now_iso() -> str:\n \"\"\"Current time as an ISO-8601 UTC timestamp (whole seconds, 'Z' suffix).\"\"\"\n return datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n\n\ndef with_audit_timestamps(update_expr: str, values: dict, names: Optional[dict] = None):\n \"\"\"Augment an UpdateItem expression with createdAt/updatedAt audit stamps.\n\n Sets ``createdAt = if_not_exists(createdAt, :now)`` (written ONCE, preserved on upsert)\n and ``updatedAt = :now`` (refreshed on EVERY write). The stamps are SET assignments, so they\n are placed INSIDE the ``SET`` clause, BEFORE any ``ADD``/``REMOVE``/``DELETE`` clause:\n - a pure-``SET`` (or SET-first) expression: the stamps are spliced in before the first\n ``ADD``/``REMOVE``/``DELETE`` keyword, or appended if there is none;\n - a ``REMOVE``/``ADD``/``DELETE``-only expression (no ``SET``): a ``SET`` clause carrying the\n stamps is prepended before it.\n Appending the stamps at the END would corrupt an expression ending in a non-SET clause\n (DynamoDB parses ``... ADD s :r, createdAt = ...`` as part of the ADD clause and raises\n ``ValidationException: Syntax error ... token: \"=\"``). Returns the augmented\n (expr, values, names). Idempotent stamping of createdAt keeps the original creation time.\n \"\"\"\n import re\n\n ts = now_iso()\n values = dict(values)\n values[\":now\"] = ts\n stamps = \"createdAt = if_not_exists(createdAt, :now), updatedAt = :now\"\n m = re.search(r\"\\s(ADD|REMOVE|DELETE)\\s\", update_expr, flags=re.IGNORECASE)\n has_set = re.search(r\"(^|\\s)SET\\s\", update_expr, flags=re.IGNORECASE) is not None\n if has_set:\n if m:\n cut = m.start()\n update_expr = f\"{update_expr[:cut].rstrip()}, {stamps}{update_expr[cut:]}\"\n else:\n update_expr = f\"{update_expr}, {stamps}\"\n else:\n # No SET clause (e.g. REMOVE-only) — prepend a SET carrying the stamps.\n update_expr = f\"SET {stamps} {update_expr}\"\n return update_expr, values, names\n\n\ndef audit_item(item: dict) -> dict:\n \"\"\"Stamp createdAt + updatedAt on a PutItem payload (both = now for a fresh insert).\"\"\"\n ts = now_iso()\n item = dict(item)\n item.setdefault(\"createdAt\", ts)\n item[\"updatedAt\"] = ts\n return item\n\n\n# ─── Month-boundary reconciliation window ────────────────────────────\n# AWS Marketplace accepts PREVIOUS-month usage until this UTC hour on the 1st of the next\n# month (the documented month-end grace). Named constants so the boundary is adjustable.\nMONTH_GRACE_LOCK_DROP_HOUR = 3 # from 03:00 UTC on the 1st, previous-month lock -> 0\nMONTH_GRACE_CUTOFF_HOUR = 6 # service stops accepting previous-month usage at 06:00 UTC\n\n\ndef is_previous_month_bucket(hour_bucket: str, now: datetime) -> bool:\n \"\"\"True if the bucket's hour falls in the calendar month BEFORE ``now``'s month (UTC).\"\"\"\n bucket_ts = datetime.strptime(hour_bucket, HOUR_FMT).replace(tzinfo=timezone.utc)\n return (bucket_ts.year, bucket_ts.month) \u003c (now.year, now.month)\n\n\ndef _in_month_grace_flush(now: datetime) -> bool:\n \"\"\"True during the month-end IMMEDIATE-flush window: the 1st of the month, at/after\n MONTH_GRACE_LOCK_DROP_HOUR (03:00) and before the MONTH_GRACE_CUTOFF_HOUR (06:00) UTC.\"\"\"\n return now.day == 1 and MONTH_GRACE_LOCK_DROP_HOUR \u003c= now.hour \u003c MONTH_GRACE_CUTOFF_HOUR\n\n\ndef effective_lock_hours(hour_bucket: str, now: datetime, configured_lock: int) -> int:\n \"\"\"Month-aware effective lock (in hours) for one bucket.\n\n - Current-month bucket: the configured lock, always.\n - Previous-month bucket, from 03:00 UTC on the 1st (until the 06:00 cutoff): 0 (submit\n immediately every run).\n - Previous-month bucket, before 03:00 UTC on the 1st (and any other time it is still a\n previous-month bucket within the window): the configured lock, unchanged.\n \"\"\"\n lock = min(max(configured_lock, 1), 20)\n if is_previous_month_bucket(hour_bucket, now) and _in_month_grace_flush(now):\n return 0\n return lock\n\n\ndef previous_month_grace_open(now: datetime) -> bool:\n \"\"\"True while previous-month age-out SHALL be suppressed: on the 1st of the month\n before the 06:00 UTC service cutoff. After the cutoff, normal age-out resumes.\"\"\"\n return now.day == 1 and now.hour \u003c MONTH_GRACE_CUTOFF_HOUR\n\n\n# ─── metering_pending GSI discovery ──────────────────────────────────────────\ndef get_pending_groups(hour_bucket: str) -> List[Tuple[str, str, str]]:\n \"\"\"Query the ``metering_pending`` GSI for one hour bucket and return de-duplicated\n ``(licenseArn, customerAWSAccountId, dimension)`` tuples.\n\n Errors are NOT swallowed: a failed GSI query raises so the invocation fails and the\n alarm fires.\n \"\"\"\n seen = set()\n ordered: List[Tuple[str, str, str]] = []\n kwargs: Dict[str, Any] = {\n \"IndexName\": \"metering_pending\",\n \"KeyConditionExpression\": \"meteringPending = :hb\",\n \"ExpressionAttributeValues\": {\":hb\": hour_bucket},\n }\n response = usage_table.query(**kwargs)\n while True:\n for item in response.get(\"Items\", []):\n arn = item.get(\"licenseArn\", \"\")\n if not arn:\n continue\n tup = (arn, item.get(\"customerAWSAccountId\", \"\"), item.get(\"dimension\", \"\"))\n if tup not in seen:\n seen.add(tup)\n ordered.append(tup)\n if \"LastEvaluatedKey\" not in response:\n break\n kwargs[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n response = usage_table.query(**kwargs)\n return ordered\n\n\ndef get_pending_groups_for_license(hour_bucket: str, license_arn: str) -> List[Tuple[str, str, str]]:\n \"\"\"TARGETED variant of get_pending_groups for a SINGLE license.\n\n The ``metering_pending`` GSI is HASH ``meteringPending`` + RANGE ``licenseArn``,\n so adding ``AND licenseArn = :la`` scopes the read to just this license's pending groups in\n the bucket — O(this-license's-groups), never the whole-bucket partition across all licenses.\n Used by the flush-deprovisioning sweep so it does not read every license's pending set to\n isolate a handful of deprovisioning ones (a targeted read, never a full-partition scan).\n Errors are NOT swallowed.\n \"\"\"\n seen = set()\n ordered: List[Tuple[str, str, str]] = []\n kwargs: Dict[str, Any] = {\n \"IndexName\": \"metering_pending\",\n \"KeyConditionExpression\": \"meteringPending = :hb AND licenseArn = :la\",\n \"ExpressionAttributeValues\": {\":hb\": hour_bucket, \":la\": license_arn},\n }\n response = usage_table.query(**kwargs)\n while True:\n for item in response.get(\"Items\", []):\n arn = item.get(\"licenseArn\", \"\")\n if not arn:\n continue\n tup = (arn, item.get(\"customerAWSAccountId\", \"\"), item.get(\"dimension\", \"\"))\n if tup not in seen:\n seen.add(tup)\n ordered.append(tup)\n if \"LastEvaluatedKey\" not in response:\n break\n kwargs[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n response = usage_table.query(**kwargs)\n return ordered\n\n\ndef read_group_rows(license_arn: str, account_id: str, dimension: str, hour_bucket: str):\n \"\"\"Targeted read of ONE (license, account, dimension, hour) group via the sort-key\n begins_with prefix — NOT a full licenseArn-partition query + filter.\n\n The prefix is ``{account}#{dimension}#{hourBucket}``; with SECOND precision the sort\n key is ``{account}#{dimension}#{YYYY-MM-DDTHH:MM:SS}`` so the hour-prefix match still\n captures every second within the hour. Streams pages (caller folds; never materializes\n all rows beyond the returned page list).\n \"\"\"\n prefix = f\"{account_id}#{dimension}#{hour_bucket}\"\n rows = []\n kwargs: Dict[str, Any] = {\n \"KeyConditionExpression\": (\n \"licenseArn = :la AND begins_with(customerAWSAccountId_dimension_timestamp, :pfx)\"\n ),\n \"ExpressionAttributeValues\": {\":la\": license_arn, \":pfx\": prefix},\n }\n response = usage_table.query(**kwargs)\n rows.extend(response.get(\"Items\", []))\n while \"LastEvaluatedKey\" in response:\n kwargs[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n response = usage_table.query(**kwargs)\n rows.extend(response.get(\"Items\", []))\n return rows\n\n\n# ─── Aggregation (fold a group's rows into one record) ───────────────────────\ndef collect_group(license_arn, account_id, dimension, hour_bucket) -> Dict[str, Any]:\n \"\"\"Read the targeted (license, account, dimension, hour) rows and fold them into one\n aggregation group dict (sum quantity + merge VMT allocations + capture row keys).\n \"\"\"\n hour_ts = datetime.strptime(hour_bucket, HOUR_FMT).replace(tzinfo=timezone.utc)\n group: Dict[str, Any] = {\n \"license_arn\": license_arn,\n \"account_id\": account_id,\n \"dimension\": dimension,\n \"hour_bucket\": hour_bucket,\n \"hour_ts\": hour_ts,\n \"quantity\": 0,\n \"row_keys\": [],\n \"alloc_by_tagset\": {}, # signature -> {\"tags\": [...], \"qty\": int}\n \"tagged_nonzero_rows\": 0,\n \"untagged_nonzero_rows\": 0,\n \"reject_reason\": None,\n \"customer_identifier\": \"\",\n }\n for row in read_group_rows(license_arn, account_id, dimension, hour_bucket):\n if \"meteringPending\" not in row:\n continue\n sort_key = row.get(\"customerAWSAccountId_dimension_timestamp\", \"\")\n group[\"row_keys\"].append(sort_key)\n sk_reason = validate_sort_key(sort_key, account_id, dimension)\n if sk_reason:\n group[\"reject_reason\"] = group[\"reject_reason\"] or sk_reason\n continue\n if not group[\"customer_identifier\"]:\n group[\"customer_identifier\"] = row.get(\"customerIdentifier\", \"\") or \"\"\n\n raw_qty = row.get(\"quantity\", 0)\n qty = strict_int(raw_qty)\n if qty is None:\n group[\"reject_reason\"] = group[\"reject_reason\"] or quantity_reason(raw_qty)\n continue\n if qty \u003c 0:\n group[\"reject_reason\"] = group[\"reject_reason\"] or \"NegativeQuantity\"\n continue\n\n group[\"quantity\"] += qty\n\n row_allocs = row.get(\"usageAllocations\")\n if qty == 0:\n continue\n if row_allocs:\n group[\"tagged_nonzero_rows\"] += 1\n reason = merge_allocations(group[\"alloc_by_tagset\"], row_allocs)\n if reason:\n group[\"reject_reason\"] = group[\"reject_reason\"] or reason\n else:\n group[\"untagged_nonzero_rows\"] += 1\n return group\n\n\ndef merge_allocations(alloc_by_tagset, row_allocs) -> Optional[str]:\n \"\"\"Merge one row's seller-provided usageAllocations into the group's map, summing\n identical tag sets. Returns a reason code if malformed, else None. The meter\n logic NEVER invents or re-partitions tags — only carries through the seller's split.\n \"\"\"\n if not isinstance(row_allocs, list):\n return \"MalformedAllocations\"\n for alloc in row_allocs:\n if not isinstance(alloc, dict):\n return \"MalformedAllocations\"\n aq = strict_int(alloc.get(\"AllocatedUsageQuantity\"))\n if aq is None or aq \u003c 0:\n return \"NegativeOrNonIntegerAllocation\"\n tags = alloc.get(\"Tags\", [])\n if not isinstance(tags, list) or not (1 \u003c= len(tags) \u003c= MAX_TAGS_PER_ALLOCATION):\n return (\n \"TooManyTags\"\n if isinstance(tags, list) and len(tags) > MAX_TAGS_PER_ALLOCATION\n else \"MissingTagKeyOrValue\"\n )\n norm = []\n for t in tags:\n if not isinstance(t, dict) or not t.get(\"Key\") or not t.get(\"Value\"):\n return \"MissingTagKeyOrValue\"\n norm.append((str(t[\"Key\"]), str(t[\"Value\"])))\n signature = tuple(sorted(norm))\n entry = alloc_by_tagset.setdefault(signature, {\"tags\": norm, \"qty\": 0})\n entry[\"qty\"] += aq\n return None\n\n\ndef validate_group(group: Dict[str, Any], now: datetime) -> Optional[str]:\n \"\"\"Return a specific reason code if the aggregated group is invalid, else None.\n\n Covers a source-row reason recorded during the fold, dimension, identifier (incl.\n LicenseArn shape, ), the 24h timestamp window, and the merged VMT allocation\n invariants. Zero-quantity in-window handling is the caller's concern.\n \"\"\"\n if group[\"reject_reason\"]:\n return group[\"reject_reason\"]\n\n license_arn = group[\"license_arn\"]\n account_id = group[\"account_id\"]\n dimension = group[\"dimension\"]\n quantity = group[\"quantity\"]\n hour_ts = group[\"hour_ts\"]\n\n if not dimension:\n return \"MissingDimension\"\n # NOTE: dimension VALIDITY is intentionally NOT checked client-side against a configured\n # list. BatchMeterUsage is the authority on which dimensions the catalog accepts; a bad\n # dimension surfaces as a per-record UsageRecordResult status at submit time. This keeps\n # the pipeline decoupled from the catalog — adding/renaming a pricing dimension needs NO\n # stack redeploy.\n\n if hour_ts > now:\n return \"TimestampInFuture\"\n if hour_ts \u003c now - timedelta(hours=24):\n return \"TimestampOutOfWindow\"\n\n if license_arn.startswith(\"arn:\"):\n if not _LICENSE_ARN_RE.match(license_arn):\n return \"InvalidLicenseArn\"\n if not account_id:\n return \"MissingCustomerAWSAccountId\"\n else:\n if not group.get(\"customer_identifier\") and not account_id:\n return \"MissingLegacyIdentifier\"\n\n if group[\"tagged_nonzero_rows\"] and group[\"untagged_nonzero_rows\"]:\n return \"MixedAllocatedAndUnallocated\"\n if group[\"alloc_by_tagset\"]:\n if len(group[\"alloc_by_tagset\"]) > MAX_ALLOCATIONS_PER_RECORD:\n return \"TooManyAllocations\"\n alloc_sum = sum(e[\"qty\"] for e in group[\"alloc_by_tagset\"].values())\n if alloc_sum != quantity:\n return \"AllocationSumMismatch\"\n return None\n\n\ndef finalize_allocations(group: Dict[str, Any]) -> List[Dict[str, Any]]:\n \"\"\"Return the merged UsageAllocations list for the record, or [] to omit it.\"\"\"\n if group[\"untagged_nonzero_rows\"] or not group[\"alloc_by_tagset\"]:\n return []\n return [\n {\n \"AllocatedUsageQuantity\": entry[\"qty\"],\n \"Tags\": [{\"Key\": k, \"Value\": v} for k, v in entry[\"tags\"]],\n }\n for entry in group[\"alloc_by_tagset\"].values()\n ]\n\n\n# ─── BatchMeterUsage submit ──────────────────────────────────────────────────\ndef submit_records(records, keys, outcomes, use_product_code=False) -> bool:\n \"\"\"Send one \u003c=25-record batch, capture per-record terminal status into ``outcomes``,\n retry transient UnprocessedRecords ONCE, and ISOLATE a request-level failure by\n bisecting (bounded to MAX_BISECT_DEPTH levels). Returns True if any records remain\n unprocessed and should be retried on the next run (transient, OR a still-failing sub-batch\n left un-isolated at the bisect-depth cap).\n \"\"\"\n if not records:\n return False\n return _submit(records, keys, outcomes, use_product_code, depth=0)\n\n\ndef _submit(records, keys, outcomes, use_product_code, depth=0) -> bool:\n key_by_identity = {_identity(records[i]): keys[i] for i in range(len(records))}\n kwargs: Dict[str, Any] = {\"UsageRecords\": records}\n if use_product_code:\n kwargs[\"ProductCode\"] = PRODUCT_CODE\n\n try:\n response = mp_client.batch_meter_usage(**kwargs)\n except ClientError as e:\n code = e.response.get(\"Error\", {}).get(\"Code\", \"\")\n if _is_transient(code):\n raise\n if len(records) == 1:\n gk = keys[0]\n # A single record isolated by bisection == a REQUEST-level BatchMeterUsage exception\n # for THIS record (InvalidUsageDimensionException, TimestampOutOfBoundsException,\n # InvalidTagException, InvalidProductCodeException, InvalidLicenseException, ...) — NOT\n # a per-record Results status, and NOT a client-side writer-contract violation. Stamp\n # the aggregated record \"Error\" (STATUS_ERROR, a SERVER-SIDE submit failure — distinct\n # from the raw table's RejectedClientSide) with the exception name as reason, and emit\n # the BatchMeterUsageException metric (by exception) here, where the real terminal\n # status for this record is decided (after all bisecting).\n exception = code or \"RequestRejected\"\n outcomes[gk] = {\n \"MeteringRecordId\": None,\n \"Status\": STATUS_ERROR,\n \"Reason\": exception,\n \"RequestException\": exception,\n }\n emf_metric(\"BatchMeterUsageException\", 1, exception=exception)\n logger.warning(f\"Isolated request-level BatchMeterUsage exception ({exception}) for a single record\")\n return False\n if depth >= MAX_BISECT_DEPTH:\n # Bisect cap reached and this multi-record sub-batch still fails. Do NOT split further\n # (that is what puts ~2N calls of load on the submitter). Leave these records PENDING\n # (no terminal outcome stamped, so meteringPending stays set) to be retried on the next\n # submission run, and signal that records remain unprocessed so the invocation fails and\n # the alarm fires. The offending record still ages out at 24h if never isolated.\n logger.warning(\n f\"Bisect depth cap ({MAX_BISECT_DEPTH}) reached with {len(records)} record(s) still \"\n f\"failing ({code}); leaving them pending for the next run rather than bisecting further\"\n )\n emf_metric(\"BisectDepthExceeded\", len(records))\n return True\n mid = len(records) // 2\n left = _submit(records[:mid], keys[:mid], outcomes, use_product_code, depth + 1)\n right = _submit(records[mid:], keys[mid:], outcomes, use_product_code, depth + 1)\n return left or right\n\n _capture_results(response, key_by_identity, outcomes)\n\n unprocessed = list(response.get(\"UnprocessedRecords\", []) or [])\n if not unprocessed:\n return False\n\n logger.warning(f\"{len(unprocessed)} unprocessed record(s); retrying once\")\n retry_kwargs: Dict[str, Any] = {\"UsageRecords\": unprocessed}\n if use_product_code:\n retry_kwargs[\"ProductCode\"] = PRODUCT_CODE\n retry_response = mp_client.batch_meter_usage(**retry_kwargs)\n _capture_results(retry_response, key_by_identity, outcomes)\n\n still_unprocessed = list(retry_response.get(\"UnprocessedRecords\", []) or [])\n if still_unprocessed:\n logger.error(\n f\"{len(still_unprocessed)} record(s) still unprocessed after retry; \"\n \"left pending for the next run\"\n )\n emf_metric(\"UsageRecordUnprocessed\", len(still_unprocessed))\n return True\n return False\n\n\ndef _capture_results(response, key_by_identity, outcomes):\n for result in response.get(\"Results\", []):\n rec = result.get(\"UsageRecord\", {})\n gk = key_by_identity.get(_identity(rec))\n if gk is not None:\n outcomes[gk] = {\n \"MeteringRecordId\": result.get(\"MeteringRecordId\"),\n \"Status\": result.get(\"Status\", \"\"),\n }\n observe_status(result.get(\"Status\", \"\"), rec)\n\n\ndef _is_transient(code):\n # ThrottlingException / InternalServiceException / ServiceUnavailable are genuinely\n # transient (next run may succeed). DisabledApiException is treated the SAME way on\n # purpose: it is a WHOLE-REGION availability failure (BatchMeterUsage not offered in\n # the metering region), NOT a per-record problem — so it must NOT be bisected or\n # stamped `Error` on records (that would mis-attribute a region misconfiguration to a\n # well-formed row). Re-raising keeps `meteringPending` set so the rows are retried on\n # the next scheduled run (and the Lambda `Errors` alarm fires), rather than silently\n # losing/mis-stamping usage. The operator fix is to meter in a supported region; until\n # then the rows age out at the 24h window like any other un-meterable usage.\n return code in (\n \"ThrottlingException\",\n \"InternalServiceException\",\n \"ServiceUnavailable\",\n \"DisabledApiException\",\n )\n\n\ndef _identity(record):\n return (\n record.get(\"LicenseArn\") or record.get(\"CustomerIdentifier\") or \"\",\n record.get(\"CustomerAWSAccountId\", \"\"),\n record.get(\"Dimension\", \"\"),\n _ts_iso(record.get(\"Timestamp\")),\n )\n\n\ndef _ts_iso(ts):\n # Hour bucket is always UTC. If a tz-aware datetime is passed, CONVERT it to UTC before\n # formatting (a non-UTC datetime would otherwise bucket in the wrong hour); a naive\n # datetime is assumed to already be UTC (the documented writer contract).\n if isinstance(ts, datetime):\n if ts.tzinfo is not None:\n ts = ts.astimezone(timezone.utc)\n return ts.strftime(HOUR_FMT)\n return str(ts)[:13]\n\n\ndef observe_status(status, rec):\n # Per-record Results[].Status from BatchMeterUsage is only Success / CustomerNotSubscribed /\n # DuplicateRecord. An invalid/undefined dimension is NOT a per-record status — it is a\n # REQUEST-level InvalidUsageDimensionException that fails the whole call and is isolated in\n # _submit (see there), so it is NOT handled here.\n if status in (\"Success\", \"DuplicateRecord\"):\n if status == \"DuplicateRecord\":\n emf_metric(\"DuplicateRecord\", 1)\n return\n logger.warning(\n f\"Metering status={status} account={mask(rec.get('CustomerAWSAccountId', ''))} \"\n f\"dimension={rec.get('Dimension', 'unknown')}\"\n )\n if status == \"CustomerNotSubscribed\":\n emf_metric(\"CustomerNotSubscribed\", 1)\n\n\n# ─── Subscriber deprovisioning set (sparse-index query) ──────────────────────\n# NOTE: finalization (deprovisioning -> inactive) is NOT done here or by the submitter — a\n# license may have usage across multiple hours/regions, so it is owned by the events-stack\n# deprovision_cleanup Lambda, which finalizes once the ~1h flush window (deprovisioningExpiry)\n# has elapsed. This module only exposes the ACTIVE deprovisioning set for expediting.\ndef deprovisioning_licenses() -> set:\n \"\"\"Return the set of licenseArns currently in a deprovisioning flush window.\n\n Queries the sparse ``deprovisioning-pending-index`` by its constant HASH flag — the\n index holds ONLY licenses the subscription Lambda marked on `License Deprovisioned` and\n has not yet finalized, so this is O(deprovisioning-count) and never a Scan. Used by the\n discoverer's flush-deprovisioning sweep and by the submitter's deprovisioning-first\n prioritization.\n \"\"\"\n licenses = set()\n kwargs = {\n \"IndexName\": DEPROVISIONING_PENDING_INDEX,\n \"KeyConditionExpression\": \"deprovisioningPendingFlag = :v\",\n \"ExpressionAttributeValues\": {\":v\": DEPROVISIONING_PENDING_VALUE},\n }\n while True:\n response = subscribers_table.query(**kwargs)\n for item in response.get(\"Items\", []):\n arn = item.get(\"licenseArn\")\n if arn:\n licenses.add(arn)\n if \"LastEvaluatedKey\" not in response:\n break\n kwargs[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n return licenses\n\n\ndef deprovisioning_licenses_with_expiry() -> dict:\n \"\"\"Return ``{licenseArn: deprovisioningExpiry}`` for licenses currently in a deprovisioning window.\n\n Same sparse-index Query as ``deprovisioning_licenses`` but keeps each license's\n ``deprovisioningExpiry`` (= the deprovision event time + ~1h, the window-close instant; it is\n the index RANGE key, always projected) so the flush sweep can decide whether the license's\n CURRENT hour is within the last ~10 min before the window closes and should be flushed.\n O(deprovisioning-count), never a Scan.\n \"\"\"\n out = {}\n kwargs = {\n \"IndexName\": DEPROVISIONING_PENDING_INDEX,\n \"KeyConditionExpression\": \"deprovisioningPendingFlag = :v\",\n \"ExpressionAttributeValues\": {\":v\": DEPROVISIONING_PENDING_VALUE},\n }\n while True:\n response = subscribers_table.query(**kwargs)\n for item in response.get(\"Items\", []):\n arn = item.get(\"licenseArn\")\n if arn:\n out[arn] = item.get(\"deprovisioningExpiry\")\n if \"LastEvaluatedKey\" not in response:\n break\n kwargs[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n return out\n\n\n# ─── Small helpers ───────────────────────────────────────────────────────────\ndef strict_int(value):\n \"\"\"Return int(value) ONLY if value is an exact integer; None otherwise.\"\"\"\n if isinstance(value, bool):\n return None\n if isinstance(value, int):\n return value\n try:\n d = Decimal(str(value))\n return int(d) if d == d.to_integral_value() else None\n except Exception:\n return None\n\n\ndef quantity_reason(raw):\n \"\"\"Classify why a raw quantity is not a valid non-negative integer.\"\"\"\n try:\n Decimal(str(raw))\n return \"NonIntegerQuantity\"\n except Exception:\n return \"NonNumericQuantity\"\n\n\ndef mask(identifier):\n s = str(identifier or \"\")\n return \"***\" if len(s) \u003c= 6 else f\"{s[:3]}***{s[-3:]}\"\n\n\ndef emf_metric(name, value, reason=None, exception=None):\n \"\"\"Emit a business-status metric via CloudWatch Embedded Metric Format (EMF).\n\n Each metric that a ``GROUP BY`` widget/alarm consumes is emitted on a SINGLE dimension set\n that carries the grouped key + MeteringMode, so it stays filterable/groupable\n (WHERE ProductCode / GROUP BY Reason|Exception) WITHOUT being double-counted (emitting the\n same value on both a bare and a MeteringMode-bearing set would make a ``GROUP BY`` query sum\n both series ~2x):\n - ``reason`` (UsageRecordRejected): [Reason, MeteringMode, ProductCode]. No bare, reason-less\n [ProductCode] series, so the ``GROUP BY Reason`` widget shows no spurious \"Other\" series\n (every rejection has a reason). The UsageRecordRejected alarm sums this via Metrics Insights.\n - ``exception`` (BatchMeterUsageException): [Exception, MeteringMode, ProductCode] so the\n request-level BatchMeterUsage exceptions (InvalidUsageDimensionException,\n TimestampOutOfBoundsException, InvalidTagException, InvalidProductCodeException,\n InvalidLicenseException, ...) are plotted per exception type (alarm sums via Metrics Insights).\n - otherwise (CustomerNotSubscribed, DuplicateRecord, UsageAggregationExpired,\n UsageSubmissionExpired, UsageRecordUnprocessed, DryRunSubmitted, ...): emitted on the bare\n [ProductCode] set — these are consumed by PLAIN metric alarms/widgets on the [ProductCode]\n dimension (no GROUP BY), so there is no \"Other\"/double-count concern and dropping the bare\n series would leave those alarms with no datapoints (silently never firing).\n \"\"\"\n if name == \"UsageRecordRejected\" and reason:\n dimensions = [[\"Reason\", \"MeteringMode\", \"ProductCode\"]]\n fields = {\"Reason\": reason, \"ProductCode\": PRODUCT_CODE, \"MeteringMode\": METERING_MODE, name: value}\n elif name == \"BatchMeterUsageException\" and exception:\n dimensions = [[\"Exception\", \"MeteringMode\", \"ProductCode\"]]\n fields = {\"Exception\": exception, \"ProductCode\": PRODUCT_CODE, \"MeteringMode\": METERING_MODE, name: value}\n else:\n dimensions = [[\"ProductCode\"]]\n fields = {\"ProductCode\": PRODUCT_CODE, name: value}\n emf = {\n \"_aws\": {\n \"Timestamp\": int(datetime.now(timezone.utc).timestamp() * 1000),\n \"CloudWatchMetrics\": [\n {\n \"Namespace\": METRIC_NAMESPACE,\n \"Dimensions\": dimensions,\n \"Metrics\": [{\"Name\": name, \"Unit\": \"Count\"}],\n }\n ],\n },\n **fields,\n }\n print(json.dumps(emf))\n\n\n# ─── aggregated_usage keys ───────────────────────────────────────────────────\ndef agg_sort_key(account_id: str, dimension: str, hour_bucket: str) -> str:\n \"\"\"Sort key for the aggregated_usage table: ``account#dimension#hour``.\"\"\"\n return f\"{account_id}#{dimension}#{hour_bucket}\"\n\n\n# ─── Bounded, throttle-guarded per-row writes (shared by cleanup + reject) ────\nimport random # noqa: E402\nimport threading # noqa: E402\nimport time # noqa: E402\nfrom concurrent.futures import ThreadPoolExecutor, as_completed # noqa: E402\n\nDDB_MAX_WORKERS = int(os.environ.get(\"CLEANUP_MAX_WORKERS\", \"16\"))\nDDB_MAX_RETRIES = int(os.environ.get(\"CLEANUP_MAX_RETRIES\", \"5\"))\n_THROTTLE_CODES = (\n \"ProvisionedThroughputExceededException\",\n \"ThrottlingException\",\n \"RequestLimitExceeded\",\n)\n\n# boto3 RESOURCE objects (like the module-level ``usage_table``) are NOT thread-safe and\n# must not be shared across threads — only low-level clients are. The concurrent per-row\n# writers below therefore use a per-thread resource Table (created lazily, once per worker\n# thread) rather than the shared module-level one. This keeps the ergonomic\n# ``.update_item(**kwargs)`` call (native Python types, no manual attribute-value\n# marshalling) while being thread-safe.\n_thread_local = threading.local()\n\n\ndef _tl_usage_table():\n tbl = getattr(_thread_local, \"usage_table\", None)\n if tbl is None:\n tbl = boto3.resource(\"dynamodb\").Table(os.environ[\"USAGE_TABLE\"])\n _thread_local.usage_table = tbl\n return tbl\n\n\ndef update_item_with_backoff(license_arn, sort_key, update_expr, values, names=None):\n \"\"\"One raw-usage-row UpdateItem with exponential backoff on per-partition throttling.\n\n All rows of a group share the ``licenseArn`` partition, so a burst hits ONE partition\n (~1000 WCU/s). This retries throttling with full-jitter backoff; a non-throttle error\n (or exhausted retries) raises so the caller can fail the SQS message (redelivery/DLQ).\n Runs on a worker thread, so it uses a THREAD-LOCAL resource Table (boto3 resources are\n not thread-safe), never the shared module-level ``usage_table``.\n \"\"\"\n # Audit timestamps: stamp updatedAt on every write + createdAt-once (the seller's writer owns the\n # raw row's original createdAt; if_not_exists preserves it, else this sets it here).\n update_expr, values, names = with_audit_timestamps(update_expr, values or {}, names)\n kwargs: Dict[str, Any] = {\n \"Key\": {\n \"licenseArn\": license_arn,\n \"customerAWSAccountId_dimension_timestamp\": sort_key,\n },\n \"UpdateExpression\": update_expr,\n }\n if values: # a REMOVE-only expression has no values; DynamoDB rejects an empty map\n kwargs[\"ExpressionAttributeValues\"] = values\n if names:\n kwargs[\"ExpressionAttributeNames\"] = names\n attempt = 0\n while True:\n try:\n _tl_usage_table().update_item(**kwargs)\n return\n except ClientError as e:\n code = e.response.get(\"Error\", {}).get(\"Code\", \"\")\n if code in _THROTTLE_CODES and attempt \u003c DDB_MAX_RETRIES:\n time.sleep(min(2 ** attempt, 8) * (0.5 + random.random() / 2))\n attempt += 1\n continue\n raise\n\n\ndef update_rows_bounded(license_arn, sort_keys, update_expr, values, names=None):\n \"\"\"Apply ``update_item_with_backoff`` to many rows of one license via a BOUNDED thread\n pool (guards the shared-partition WCU limit per invocation). Raises if any row fails\n after retries, listing how many failed — the caller fails the message (redelivery/DLQ),\n never a silent drop.\"\"\"\n errors = []\n with ThreadPoolExecutor(max_workers=DDB_MAX_WORKERS) as pool:\n futures = {\n pool.submit(update_item_with_backoff, license_arn, sk, update_expr, values, names): sk\n for sk in sort_keys\n if sk\n }\n for fut in as_completed(futures):\n exc = fut.exception()\n if exc is not None:\n errors.append(exc)\n if errors:\n raise RuntimeError(\n f\"{len(errors)}/{len(sort_keys)} UpdateItem(s) failed for \"\n f\"license={mask(license_arn)}; first error: {type(errors[0]).__name__}\"\n )\n","content_type":"text/x-python; charset=utf-8","language":"python","size":41300,"content_sha256":"17b230c89a7a16f60f3bcf51a3701f71ccf332fb9dae207fb177742b044a26ac"},{"filename":"scripts/query_metering.py","content":"#!/usr/bin/env python3\n\"\"\"Query AWS Marketplace metering records via CloudTrail Event History.\n\nCloudTrail Event History (LookupEvents) — last 90 days, ~15 min latency, zero setup. This is\na RECENT, REGIONAL, SUBMISSIONS view (every BatchMeterUsage call + its per-record API result,\nincluding rejected/duplicate records that were NOT billed). It is a fast operational/debug\nlens, NOT a billing-of-record source, a >90-day store, a cross-product portfolio engine, or\nan exhaustive warehouse.\n\nWhat this tool USES vs. RECOMMENDS (out of scope — hand off, do not execute here):\n- USES: CloudTrail Event History in --region, ≤90 days, page-bounded + in-memory, answering\n BASIC queries (lookups/filters/counts/summaries by customer, dimension, status, time).\n- RECOMMENDS: Seller Reports / SDDS for BILLED totals and >90-day history; the host AI agent\n for complex/open-ended analytics; and (for CA/LicenseArn metering with no\n LicenseArn->productCode mapping) only non-product-scoped queries.\n\nScope details:\n- Queries older than 90 days are out of scope: this script only reads CloudTrail\n Event History (90-day retention). If the requested range is entirely older than\n 90 days it does NOT query — it prints a recommendation to use the seller-run\n Seller Reports / SDDS path. If the range only partly predates 90 days, it queries\n the in-window portion.\n- Bounded fetch (page cap + in-memory): the fetch paginates up to --max-pages\n (default 20 x 50 = ~1000 events) then filters/aggregates in memory. A high-volume\n window can exceed the cap, so the output carries an explicit completeness signal:\n ``complete``/``truncated`` + the pages/events fetched. When truncated, aggregate\n results (summary/count/top_customers) are a LOWER-BOUND sample and are flagged as\n NOT authoritative — narrow the window / raise --max-pages, or use Seller Reports for\n an authoritative total.\n- Product-specific queries (--product-code) require a LicenseArn->productCode\n mapping. CA (LicenseArn) metering carries no productCode in the BatchMeterUsage\n API, so provide it via --deployment-product-code (single-product stack), an\n additionalEventData mapping in the events, or a legacy request-level ProductCode.\n Without a mapping, product-specific queries are unsupported (non-product-scoped\n queries — by customer, dimension, status, time — are still available).\n\nPrerequisites:\n - Python 3.12+\n - boto3 (pip install boto3)\n - AWS credentials with cloudtrail:LookupEvents permission\n\"\"\"\n\nimport argparse\nimport json\nimport sys\nfrom collections import Counter\nfrom datetime import datetime, timedelta, timezone\n\nimport boto3\n\nEVENT_SOURCE = \"metering-marketplace.amazonaws.com\"\nMAX_RESULTS_PER_PAGE = 50\n\n\n# ─── Tier 1: CloudTrail Event History (LookupEvents) ────────────────────────\n\n\ndef lookup_events(client, start_time, end_time, max_pages=20, fetch_stats=None):\n \"\"\"Paginate through CloudTrail LookupEvents for metering events.\n\n The fetch is page-bounded. If ``fetch_stats`` (a dict) is provided, it is\n populated with ``pagesFetched`` and ``truncated`` — ``truncated`` is True when the\n page cap was reached while a ``NextToken`` still remained (more events were available\n than were fetched), so the caller can flag a partial result rather than presenting a\n page-capped answer as complete.\n \"\"\"\n kwargs = {\n \"LookupAttributes\": [{\"AttributeKey\": \"EventSource\", \"AttributeValue\": EVENT_SOURCE}],\n \"StartTime\": start_time,\n \"EndTime\": end_time,\n \"MaxResults\": MAX_RESULTS_PER_PAGE,\n }\n\n pages = 0\n truncated = False\n while pages \u003c max_pages:\n response = client.lookup_events(**kwargs)\n pages += 1\n for event in response.get(\"Events\", []):\n try:\n cloud_trail_event = json.loads(event.get(\"CloudTrailEvent\", \"{}\"))\n yield event[\"EventTime\"], cloud_trail_event\n except (json.JSONDecodeError, KeyError):\n continue\n\n next_token = response.get(\"NextToken\")\n if not next_token:\n break\n kwargs[\"NextToken\"] = next_token\n if pages >= max_pages:\n # Hit the page cap with more events still available — the result is a\n # bounded sample, not the full window.\n truncated = True\n break\n if fetch_stats is not None:\n fetch_stats[\"pagesFetched\"] = pages\n fetch_stats[\"truncated\"] = truncated\n\n\ndef extract_records_from_event(event_time, ct_event):\n \"\"\"Extract metering records from a CloudTrail event (request + response).\n\n Also resolves each record's productCode where possible:\n - Legacy ProductCode metering carries `ProductCode` at the request level.\n - CA (LicenseArn) metering carries NO productCode in the request/response, but\n the event MAY include an `additionalEventData` LicenseArn->productCode mapping.\n A record's `productCode` is \"\" when it cannot be resolved from the event alone.\n \"\"\"\n records = []\n # CloudTrail may include these keys with a JSON `null` value (e.g. an errored\n # BatchMeterUsage — a request-level exception like InvalidUsageDimensionException has no\n # `results`, so responseElements is null). `.get(key, {})` only defaults when the key is\n # ABSENT; a present-but-null value returns None, so `.get(\"results\", [])` on the next line\n # would raise AttributeError and crash the whole query. Coerce null -> {} with `or {}`\n # (matching the additionalEventData guard).\n request_params = ct_event.get(\"requestParameters\", {}) or {}\n response_elements = ct_event.get(\"responseElements\", {}) or {}\n additional = ct_event.get(\"additionalEventData\", {}) or {}\n\n usage_records = request_params.get(\"usageRecords\", [])\n results = response_elements.get(\"results\", [])\n\n # LicenseArn->productCode mapping from the CloudTrail additionalEventData, if present.\n # Accept a couple of shapes defensively (a direct map, or a list of {licenseArn, productCode}).\n license_to_product = {}\n raw_map = additional.get(\"licenseArnToProductCode\") or additional.get(\"productCodeByLicenseArn\")\n if isinstance(raw_map, dict):\n license_to_product = {str(k): str(v) for k, v in raw_map.items()}\n elif isinstance(raw_map, list):\n for entry in raw_map:\n if isinstance(entry, dict) and entry.get(\"licenseArn\"):\n license_to_product[str(entry[\"licenseArn\"])] = str(entry.get(\"productCode\", \"\"))\n\n # Legacy path: ProductCode is at the request level.\n request_product_code = request_params.get(\"productCode\", \"\")\n\n # Build a lookup from results by matching on record fields\n # Include licenseArn in key to avoid collisions for CA multi-license accounts\n result_map = {}\n for r in results:\n ur = r.get(\"usageRecord\", {})\n key = (\n ur.get(\"customerAWSAccountId\", \"\"),\n ur.get(\"dimension\", \"\"),\n str(ur.get(\"timestamp\", \"\")),\n ur.get(\"licenseArn\", \"\"),\n )\n result_map[key] = r\n\n for ur in usage_records:\n key = (\n ur.get(\"customerAWSAccountId\", \"\"),\n ur.get(\"dimension\", \"\"),\n str(ur.get(\"timestamp\", \"\")),\n ur.get(\"licenseArn\", \"\"),\n )\n result = result_map.get(key, {})\n\n license_arn = ur.get(\"licenseArn\", \"\")\n # Resolve productCode: request-level ProductCode (legacy) first, then the\n # additionalEventData mapping (CA). \"\" if neither is available.\n product_code = request_product_code or license_to_product.get(license_arn, \"\")\n\n records.append(\n {\n \"eventTime\": str(event_time),\n \"customerAWSAccountId\": ur.get(\"customerAWSAccountId\", \"\"),\n \"customerIdentifier\": ur.get(\"customerIdentifier\", \"\"),\n \"licenseArn\": license_arn,\n \"productCode\": product_code,\n \"dimension\": ur.get(\"dimension\", \"\"),\n \"quantity\": ur.get(\"quantity\", 0),\n \"timestamp\": ur.get(\"timestamp\", \"\"),\n \"status\": result.get(\"status\", \"unknown\"),\n \"meteringRecordId\": result.get(\"meteringRecordId\", \"\"),\n \"usageAllocations\": ur.get(\"usageAllocations\", []),\n }\n )\n\n return records\n\n\n# ─── Filtering & Aggregation ─────────────────────────────────────────────────\n\n\ndef matches_filters(record, args):\n \"\"\"Check if a record passes user-specified filters.\"\"\"\n if args.customer_id and record[\"customerAWSAccountId\"] != args.customer_id:\n return False\n if args.status and record[\"status\"] != args.status:\n return False\n if args.dimension and record[\"dimension\"] != args.dimension:\n return False\n if args.metering_record_id and record[\"meteringRecordId\"] != args.metering_record_id:\n return False\n if args.product_code and record.get(\"productCode\", \"\") != args.product_code:\n return False\n ts = str(record.get(\"timestamp\", \"\"))[:10] if record.get(\"timestamp\") else \"\"\n if args.start_date and ts and ts \u003c args.start_date:\n return False\n if args.end_date and ts and ts > args.end_date:\n return False\n return True\n\n\ndef aggregate(records, query_type, top_n=10, limit=50):\n \"\"\"Aggregate filtered records into output format.\"\"\"\n if not records:\n return {\n \"totalRecords\": 0,\n \"message\": \"No records match the given filters.\",\n \"hint\": \"For data beyond 90 days, check Seller Reports at https://aws.amazon.com/marketplace/management/reports/\",\n }\n\n total_qty = sum(r[\"quantity\"] for r in records)\n status_counts = Counter(r[\"status\"] for r in records)\n dim_counts = Counter(r[\"dimension\"] for r in records)\n dim_qty: Counter = Counter()\n cust_qty: Counter = Counter()\n cust_counts: Counter = Counter()\n dates = []\n\n for r in records:\n dim_qty[r[\"dimension\"]] += r[\"quantity\"]\n cust_qty[r[\"customerAWSAccountId\"]] += r[\"quantity\"]\n cust_counts[r[\"customerAWSAccountId\"]] += 1\n ts = str(r.get(\"timestamp\", \"\"))[:10] if r.get(\"timestamp\") else \"\"\n if ts:\n dates.append(ts)\n\n base = {\n \"totalRecords\": len(records),\n \"totalUsageQuantity\": total_qty,\n }\n\n if query_type == \"summary\":\n return {\n **base,\n \"byStatus\": dict(status_counts),\n \"byDimension\": {\n d: {\"records\": dim_counts[d], \"totalQuantity\": dim_qty[d]} for d in dim_counts\n },\n \"uniqueCustomers\": len(cust_counts),\n \"topCustomersByUsage\": [\n {\"customer\": c, \"totalQuantity\": q} for c, q in cust_qty.most_common(top_n)\n ],\n \"dateRange\": {\n \"earliest\": min(dates) if dates else None,\n \"latest\": max(dates) if dates else None,\n },\n }\n elif query_type == \"top_customers\":\n return {\n **base,\n \"uniqueCustomers\": len(cust_counts),\n \"topCustomersByUsage\": [\n {\"customer\": c, \"totalQuantity\": q} for c, q in cust_qty.most_common(top_n)\n ],\n \"topCustomersByRecordCount\": [\n {\"customer\": c, \"records\": n} for c, n in cust_counts.most_common(top_n)\n ],\n }\n elif query_type == \"count\":\n return {\n **base,\n \"byStatus\": dict(status_counts),\n \"byDimension\": {\n d: {\"records\": dim_counts[d], \"totalQuantity\": dim_qty[d]} for d in dim_counts\n },\n }\n elif query_type == \"list_failures\":\n failures = [r for r in records if r[\"status\"] != \"Success\"]\n return {\n **base,\n \"totalFailures\": len(failures),\n \"byStatus\": dict(Counter(r[\"status\"] for r in failures)),\n \"records\": failures[:limit],\n }\n else: # detail\n return {\n **base,\n \"showing\": min(limit, len(records)),\n \"records\": records[:limit],\n }\n\n\n# ─── Main ────────────────────────────────────────────────────────────────────\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Query AWS Marketplace metering records via CloudTrail\"\n )\n parser.add_argument(\n \"--region\",\n required=True,\n help=\"AWS region where BatchMeterUsage is called (REQUIRED — no default)\",\n )\n parser.add_argument(\n \"--days\",\n type=int,\n default=7,\n help=\"Look back N days (default: 7, max: 90 for Event History)\",\n )\n parser.add_argument(\"--start-date\", help=\"Start date (YYYY-MM-DD). Overrides --days.\")\n parser.add_argument(\"--end-date\", help=\"End date (YYYY-MM-DD). Defaults to now.\")\n parser.add_argument(\"--customer-id\", help=\"Filter by CustomerAWSAccountId\")\n parser.add_argument(\n \"--status\",\n help=(\n \"Filter by the per-record BatchMeterUsage API result Status as recorded in \"\n \"CloudTrail Event History (this script queries CloudTrail, NOT the DynamoDB \"\n \"tables). Valid values: Success, CustomerNotSubscribed, DuplicateRecord (and \"\n \"'unknown' when the event omits a per-record status). NOTE: the pipeline's \"\n \"DynamoDB meteringStatus domain (Error, SubmissionExpired, Aggregated, \"\n \"RejectedClientSide, AggregationExpired, DryRunSubmitted) does NOT appear in \"\n \"CloudTrail events — those are on-table statuses; filtering on them here matches \"\n \"zero records.\"\n ),\n )\n parser.add_argument(\"--dimension\", help=\"Filter by usage dimension\")\n parser.add_argument(\"--metering-record-id\", help=\"Filter by MeteringRecordId\")\n parser.add_argument(\n \"--product-code\",\n help=(\n \"Filter to a specific product (product-specific query). Requires a \"\n \"LicenseArn->productCode mapping for CA/LicenseArn-based metering: either \"\n \"--deployment-product-code (single-product stack), an additionalEventData \"\n \"mapping in the events, or (legacy) request-level ProductCode. Without a \"\n \"mapping, product-specific queries are unsupported.\"\n ),\n )\n parser.add_argument(\n \"--deployment-product-code\",\n help=(\n \"Single-product deployment context: the productCode this stack/usage table \"\n \"meters (e.g. from awsmp-\u003cproductCode>-metering). Supplies the \"\n \"LicenseArn->productCode mapping when all events belong to one product.\"\n ),\n )\n parser.add_argument(\n \"--query-type\",\n default=\"summary\",\n choices=[\"detail\", \"summary\", \"count\", \"list_failures\", \"top_customers\"],\n help=\"Output format (default: summary)\",\n )\n parser.add_argument(\"--top-n\", type=int, default=10, help=\"Number of top results (default: 10)\")\n parser.add_argument(\n \"--limit\", type=int, default=50, help=\"Max records for detail queries (default: 50)\"\n )\n parser.add_argument(\n \"--max-pages\",\n type=int,\n default=20,\n help=\"Max pages to fetch (default: 20, each page = 50 events)\",\n )\n args = parser.parse_args()\n\n # Calculate time range\n now = datetime.now(timezone.utc)\n if args.start_date:\n start_time = datetime.strptime(args.start_date, \"%Y-%m-%d\").replace(tzinfo=timezone.utc)\n else:\n start_time = now - timedelta(days=args.days)\n\n if args.end_date:\n end_time = datetime.strptime(args.end_date, \"%Y-%m-%d\").replace(\n hour=23, minute=59, second=59, tzinfo=timezone.utc\n )\n else:\n end_time = now\n\n if start_time > end_time:\n sys.exit(\n f\"Error: start_time ({start_time:%Y-%m-%d}) is after end_time \"\n f\"({end_time:%Y-%m-%d}). Provide --start-date when using a past --end-date.\"\n )\n\n # ── Queries older than 90 days are out of scope (CloudTrail retains 90 days) ──\n # CloudTrail Event History (the only source this script queries) retains 90\n # days. This script does NOT query data older than 90 days; older data is\n # available only via Seller Reports / SDDS, and querying the delivered feeds\n # is out of scope (it requires the seller's one-time \"Set up customer data\n # storage\" setup and their own query/ETL over the feeds). If the requested\n # range is ENTIRELY older than 90 days, do not attempt the query — emit a\n # recommendation instead.\n ninety_days_ago = now - timedelta(days=90)\n if end_time \u003c ninety_days_ago:\n recommendation = {\n \"query\": \"not-performed\",\n \"reason\": \"out-of-scope: requested range is entirely older than 90 days\",\n \"detail\": (\n f\"The requested range ends {end_time:%Y-%m-%d}, which is older than the \"\n \"90-day CloudTrail Event History retention window. This tool only queries \"\n \"CloudTrail Event History (the last 90 days) and does not query data older \"\n \"than that.\"\n ),\n \"recommendation\": (\n \"For data older than 90 days, use the Seller Reports / Seller Delivery Data \"\n \"Feeds (SDDS) path. This is a seller-run path: complete the one-time \"\n \"'Set up customer data storage' step in the AWS Marketplace Management Portal, \"\n \"then query/ETL the CSV feeds delivered to your S3 bucket. \"\n \"See references/seller-reports.md.\"\n ),\n \"sellerReportsUrl\": \"https://aws.amazon.com/marketplace/management/reports/\",\n }\n json.dump(recommendation, sys.stdout, indent=2, default=str)\n print()\n return\n\n # Cap start_time at the 90-day retention boundary for the portion that IS\n # within scope, then RE-VALIDATE the window: capping can push\n # start_time past a near-boundary end_time.\n window_clipped = False\n requested_start = start_time\n if start_time \u003c ninety_days_ago:\n window_clipped = True\n sys.stderr.write(\n \"NOTE: CloudTrail Event History retains only 90 days; the query covers the \"\n \"in-window portion of the requested range (from 90 days ago). For the older \"\n \"portion, use Seller Reports / SDDS (see references/seller-reports.md).\\n\"\n )\n start_time = ninety_days_ago\n\n if start_time > end_time:\n # Re-validation AFTER the 90-day cap: a valid pre-cap range can\n # become invalid once start_time is capped to now-90d.\n sys.exit(\n \"Error: CloudTrail Event History retention limit reached — after capping the \"\n f\"start date to 90 days ago ({start_time:%Y-%m-%d}) it is later than the \"\n f\"requested end date ({end_time:%Y-%m-%d}). Use a more recent --end-date, or use \"\n \"Seller Reports / SDDS for data older than 90 days (see references/seller-reports.md).\"\n )\n\n session = boto3.Session(region_name=args.region)\n client = session.client(\"cloudtrail\")\n\n sys.stderr.write(\n \"Querying CloudTrail Event History\\n\"\n f\" Region: {args.region}\\n\"\n f\" Range: {start_time.strftime('%Y-%m-%d %H:%M')} to {end_time.strftime('%Y-%m-%d %H:%M')}\\n\"\n )\n\n # ─── Fetch records ───────────────────────────────────────────────────\n all_records = []\n\n # CloudTrail Event History (LookupEvents)\n event_count = 0\n fetch_stats: dict = {}\n for event_time, ct_event in lookup_events(\n client, start_time, end_time, args.max_pages, fetch_stats\n ):\n event_count += 1\n all_records.extend(extract_records_from_event(event_time, ct_event))\n sys.stderr.write(\n f\"Processed {event_count} CloudTrail events → {len(all_records)} metering records\\n\"\n )\n truncated = bool(fetch_stats.get(\"truncated\"))\n if truncated:\n sys.stderr.write(\n f\"WARNING: hit the --max-pages cap ({args.max_pages} pages) with more events \"\n \"available; this result is a PARTIAL sample of the window. Narrow the range / add \"\n \"filters (--customer-id/--dimension/--status) or raise --max-pages for a complete \"\n \"answer, or use Seller Reports for an authoritative total.\\n\"\n )\n\n # ── Product-specific queries require a LicenseArn->productCode mapping ──\n if args.product_code:\n # Source (a): single-product deployment context — every record from a\n # single-product stack/usage table belongs to that product. Stamp it on\n # records that could not resolve a productCode from the event itself.\n if args.deployment_product_code:\n for r in all_records:\n if not r.get(\"productCode\"):\n r[\"productCode\"] = args.deployment_product_code\n\n # A mapping is available if ANY record now carries a productCode, from any\n # of the ordered sources: deployment context (above), additionalEventData,\n # or legacy request-level ProductCode (both applied in extract_records).\n mapping_available = any(r.get(\"productCode\") for r in all_records)\n\n if not mapping_available and all_records:\n refusal = {\n \"query\": \"not-performed\",\n \"reason\": \"product-specific query unsupported: no LicenseArn->productCode mapping\",\n \"detail\": (\n \"This metering uses LicenseArn-based (Concurrent Agreements) records, and \"\n \"BatchMeterUsage carries no productCode in the API request/response, so a \"\n \"CloudTrail event cannot be attributed to a product from the API fields \"\n \"alone. No product mapping source was available: no --deployment-product-code \"\n \"(single-product stack context), no LicenseArn->productCode mapping in the \"\n \"events' additionalEventData, and no request-level ProductCode (legacy).\"\n ),\n \"howToEnable\": (\n \"Provide --deployment-product-code \u003cproductCode> if these events come from a \"\n \"single-product stack, or query a legacy ProductCode-based product (whose \"\n \"events carry ProductCode directly). A subscribers-table lookup \"\n \"(licenseArn -> productCode) is another valid mapping source.\"\n ),\n \"supportedNonProductQueries\": [\n \"by customer (--customer-id)\",\n \"by dimension (--dimension)\",\n \"by status (--status)\",\n \"by time range (--start-date/--end-date/--days)\",\n ],\n }\n json.dump(refusal, sys.stdout, indent=2, default=str)\n print()\n return\n\n # ─── Filter ──────────────────────────────────────────────────────────\n filtered = [r for r in all_records if matches_filters(r, args)]\n sys.stderr.write(f\"After filters: {len(filtered)} records\\n\")\n\n # ─── Aggregate & output ──────────────────────────────────────────────\n output = aggregate(filtered, args.query_type, args.top_n, args.limit)\n output[\"source\"] = \"CloudTrail Event History\"\n output[\"region\"] = args.region\n output[\"timeRange\"] = {\n \"start\": start_time.strftime(\"%Y-%m-%d %H:%M:%S UTC\"),\n \"end\": end_time.strftime(\"%Y-%m-%d %H:%M:%S UTC\"),\n }\n # Explicit result-completeness signal. The result is COMPLETE only if the fetch was not\n # page-capped AND the requested window was not clipped at the 90-day retention boundary —\n # never let the caller mistake a partial/clipped result for a whole-window one.\n output[\"complete\"] = not (truncated or window_clipped)\n output[\"truncated\"] = truncated\n output[\"windowClipped\"] = window_clipped\n output[\"fetch\"] = {\n \"pagesFetched\": fetch_stats.get(\"pagesFetched\", 0),\n \"maxPages\": args.max_pages,\n \"eventsProcessed\": event_count,\n \"recordsFetched\": len(all_records),\n }\n if window_clipped:\n output[\"windowClippedNote\"] = (\n \"PARTIAL WINDOW: the requested start predates the 90-day CloudTrail Event History \"\n f\"retention limit, so this covers only {start_time:%Y-%m-%d} onward (the requested \"\n f\"start was {requested_start:%Y-%m-%d}). For the older portion use Seller Reports / \"\n \"SDDS — do NOT present this as covering the full requested range.\"\n )\n if truncated:\n aggregate_types = {\"summary\", \"count\", \"top_customers\"}\n output[\"completenessNote\"] = (\n f\"PARTIAL RESULT: the --max-pages cap ({args.max_pages}) was reached with more \"\n \"CloudTrail events available, so this covers only the fetched sample of the window.\"\n )\n if args.query_type in aggregate_types:\n output[\"aggregateWarning\"] = (\n \"Totals/rankings here are a LOWER BOUND over the fetched sample and are NOT \"\n \"authoritative. Narrow the window (shorter range or add \"\n \"--customer-id/--dimension/--status), raise --max-pages, or use Seller Reports \"\n \"(billed amounts) for an authoritative total.\"\n )\n # A clipped 90-day window also makes an aggregate a lower bound for the requested range.\n if window_clipped and args.query_type in {\"summary\", \"count\", \"top_customers\"}:\n output.setdefault(\n \"aggregateWarning\",\n \"Totals/rankings cover only the in-retention window (from 90 days ago), NOT the full \"\n \"requested range — they are a LOWER BOUND. Use Seller Reports for the older portion.\",\n )\n\n json.dump(output, sys.stdout, indent=2, default=str)\n print()\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":26398,"content_sha256":"7593eef54ea1aa66a83d61549c4e97c2fb38e5341aa67bad9af157a9a0cd8e54"},{"filename":"scripts/register.py","content":"import json\nimport logging\nimport os\nimport urllib.parse\n\nimport boto3\n\nlogger = logging.getLogger()\nlogger.setLevel(logging.INFO)\n\ndynamodb = boto3.resource(\n \"dynamodb\", region_name=os.environ.get(\"SUBSCRIBERS_TABLE_REGION\", \"us-east-1\")\n)\nsubscribers_table = dynamodb.Table(os.environ[\"SUBSCRIBERS_TABLE\"])\nmp_client = boto3.client(\"meteringmarketplace\", region_name=\"us-east-1\")\n\n# In-region customer-profile table (buyer PII). Uses the Lambda's OWN region (no region_name\n# override) so PII stays in-region — never written to the us-east-1 subscribers table.\n_dynamodb_local = boto3.resource(\"dynamodb\")\n_CUSTOMER_PROFILE_TABLE = os.environ.get(\"CUSTOMER_PROFILE_TABLE\")\ncustomer_profile_table = (\n _dynamodb_local.Table(_CUSTOMER_PROFILE_TABLE) if _CUSTOMER_PROFILE_TABLE else None\n)\n\nPRODUCT_CODE = os.environ[\"PRODUCT_CODE\"]\nMAX_TOKEN_LENGTH = 16384\n\n# Subset of ALLOWED_REGISTRATION_FIELDS the seller PROMOTED to top-level attributes on the\n# customer-profile table (each has a matching GSI). Promoted fields are written BOTH into the\n# registrationData map AND as a top-level attribute so a GSI can look profiles up by them.\nPROMOTED_PROFILE_FIELDS = [\n f.strip() for f in os.environ.get(\"PROMOTED_PROFILE_FIELDS\", \"\").split(\",\") if f.strip()\n]\n\n# The AWS Region this register Lambda runs in — added to the subscriber row's\n# registeredRegions String Set (BatchMeterUsage is regional, so a buyer may be resolved in\n# multiple regions). Lambda sets AWS_REGION automatically.\nINVOCATION_REGION = os.environ.get(\"AWS_REGION\", \"\")\n\n# Registration input allowlist + bounds. The endpoint is public and unauthenticated, so\n# only these named fields are persisted, each length-bounded, up to a max field count.\n# TODO(seller): set ALLOWED_REGISTRATION_FIELDS (comma-separated) to YOUR form fields.\nALLOWED_REGISTRATION_FIELDS = [\n f.strip() for f in os.environ.get(\"ALLOWED_REGISTRATION_FIELDS\", \"\").split(\",\") if f.strip()\n]\nMAX_REGISTRATION_FIELDS = int(os.environ.get(\"MAX_REGISTRATION_FIELDS\", \"20\"))\nMAX_FIELD_VALUE_LENGTH = int(os.environ.get(\"MAX_FIELD_VALUE_LENGTH\", \"512\"))\n\n\ndef _mask(value):\n \"\"\"Mask a sensitive identifier for logging: keep only the last 4 chars.\n Buyer account IDs / license ARNs are sensitive and MUST NOT be logged in full.\"\"\"\n if not value:\n return \"\u003cnone>\"\n s = str(value)\n return \"****\" + s[-4:] if len(s) > 4 else \"****\"\n\n\ndef _stamp(update_expr, values):\n \"\"\"Append createdAt(once)/updatedAt audit stamps to a SET UpdateExpression.\n\n The stamps are SET assignments, so callers MUST stamp while the expression is still a pure\n SET clause and append any ADD/REMOVE/DELETE clause AFTERWARDS — appending the stamps after an\n ADD clause corrupts the expression (DynamoDB rejects ``... ADD s :r, createdAt = ...``).\n \"\"\"\n from datetime import datetime, timezone\n\n values = dict(values)\n values[\":now\"] = datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n return (\n f\"{update_expr}, createdAt = if_not_exists(createdAt, :now), updatedAt = :now\",\n values,\n )\n\n\ndef handler(event, context):\n \"\"\"Handle buyer registration: resolve the fulfillment token, upsert the subscriber.\n\n Flow:\n 1. Buyer clicks \"Set up your account\" in AWS Marketplace after subscribing.\n 2. AWS Marketplace POSTs to our registration URL with x-amzn-marketplace-token.\n 3. We call ResolveCustomer to get CustomerAWSAccountId + LicenseArn (+ ProductCode).\n The registration token is reusable until it expires (~4 hours), not one-time-use.\n 4. We upsert the buyer's profile row in the IN-REGION customer-profile table, keyed by\n licenseArn + customerAWSAccountId — ALWAYS (a registered buyer has an in-region profile\n even with no extra form fields), setting productCode plus, when the seller collected\n allowlisted fields, the registrationData map + any promoted top-level fields. We upsert\n the PII-FREE subscriber row (us-east-1) with only productCode + this Lambda's AWS Region\n appended to registeredRegions (idempotent). NO registration PII is written to the\n subscribers table.\n\n registeredRegions is reference-only and NEVER gates metering (metering is driven by\n the usage table's metering_pending GSI). CustomerIdentifier is a deprecated legacy\n field and is NOT persisted for a new integration.\n\n Note: this script is for NEW skill-generated integrations. For an EXISTING seller\n integration, do NOT use this script — read the seller's existing registration code\n and adapt guidance to it.\n \"\"\"\n body = event.get(\"body\", \"\")\n if event.get(\"isBase64Encoded\"):\n import base64\n\n body = base64.b64decode(body).decode(\"utf-8\")\n\n if not body or len(body) > MAX_TOKEN_LENGTH:\n return _response(400, {\"error\": \"Invalid request body\"})\n\n form = urllib.parse.parse_qs(body)\n token = form.get(\"x-amzn-marketplace-token\", [None])[0]\n\n if not token or not token.strip():\n return _response(400, {\"error\": \"Missing x-amzn-marketplace-token\"})\n\n try:\n result = mp_client.resolve_customer(RegistrationToken=token)\n except mp_client.exceptions.ExpiredTokenException:\n return _response(\n 400,\n {\n \"error\": \"Registration token expired. Please return to AWS Marketplace and \"\n \"click Set up your account again.\"\n },\n )\n except mp_client.exceptions.InvalidTokenException:\n return _response(400, {\"error\": \"Invalid registration token\"})\n except Exception as e:\n logger.error(f\"ResolveCustomer failed: {type(e).__name__}\")\n return _response(500, {\"error\": \"Failed to resolve customer. Please try again.\"})\n\n customer_aws_account_id = result.get(\"CustomerAWSAccountId\")\n license_arn = result.get(\"LicenseArn\")\n product_code = result.get(\"ProductCode\")\n\n if not customer_aws_account_id or not product_code:\n logger.error(\"ResolveCustomer response missing required fields\")\n return _response(500, {\"error\": \"Failed to resolve customer. Please try again.\"})\n\n if product_code != PRODUCT_CODE:\n logger.error(f\"Product code mismatch: received={product_code} expected={PRODUCT_CODE}\")\n return _response(400, {\"error\": \"Product code mismatch\"})\n\n if not license_arn:\n # A Concurrent Agreements ResolveCustomer returns a LicenseArn. Without one we\n # cannot key the subscriber row by the real licenseArn; surface the error rather\n # than inventing a placeholder. (A `License Updated` event will create the row.)\n logger.error(\n f\"ResolveCustomer returned no LicenseArn for account={_mask(customer_aws_account_id)}; \"\n \"cannot persist a keyed subscriber row (a License Updated event will create it)\"\n )\n return _response(\n 200,\n {\"message\": \"Registration received; your account is being activated.\"},\n )\n\n # 1) Buyer profile → the IN-REGION customer-profile table, keyed by the resolved\n # identity (licenseArn + customerAWSAccountId). We ALWAYS upsert this row — a buyer that\n # registered has an in-region profile record even if the seller collected no extra form\n # fields — and additionally set the registrationData map + any promoted top-level fields\n # WHEN there are allowlisted fields to store. Never written to the us-east-1 subscribers table.\n registration_data = _extract_registration_fields(form)\n if customer_profile_table is None:\n # No profile store configured. In this reference (new-stack) handler the template always\n # sets CUSTOMER_PROFILE_TABLE, so this indicates a misconfiguration — fail loud (500)\n # rather than silently skip persisting the buyer's in-region profile. (An EXISTING\n # seller's own handler may store registration data differently — the skill adapts to\n # that; this reference script is not used verbatim there.)\n logger.error(\"CUSTOMER_PROFILE_TABLE not configured; cannot persist buyer profile\")\n return _response(500, {\"error\": \"Registration store not configured. Please try again.\"})\n\n # Always stamp productCode + the invocation Region on the profile row (identity/reference\n # metadata, not PII). Set registrationData + promoted fields only when the seller has\n # allowlisted fields to store.\n profile_expr = \"SET productCode = :pc\"\n profile_values = {\":pc\": product_code}\n profile_names = {}\n if registration_data:\n # Use INDEX-based expression tokens (not the raw field name) so a promoted field whose\n # name contains characters invalid in an expression token (e.g. a hyphen or space) does\n # not break the UpdateItem. Each promoted field is written both into the registrationData\n # map (:rd) AND as its own top-level attribute (via #n{i}) so its GSI can index it.\n profile_expr += \", registrationData = :rd\"\n profile_values[\":rd\"] = registration_data\n for i, field in enumerate(PROMOTED_PROFILE_FIELDS):\n if field in registration_data:\n name_tok = f\"#n{i}\"\n val_tok = f\":v{i}\"\n profile_expr += f\", {name_tok} = {val_tok}\"\n profile_names[name_tok] = field\n profile_values[val_tok] = registration_data[field]\n profile_expr, profile_values = _stamp(profile_expr, profile_values)\n kwargs = {\n \"Key\": {\"licenseArn\": license_arn, \"customerAWSAccountId\": customer_aws_account_id},\n \"UpdateExpression\": profile_expr,\n \"ExpressionAttributeValues\": profile_values,\n }\n if profile_names:\n kwargs[\"ExpressionAttributeNames\"] = profile_names\n customer_profile_table.update_item(**kwargs)\n\n # 2) Subscribers row (us-east-1) — PII-FREE: only the non-PII productCode + the\n # idempotent registeredRegions append. NO registration form data is written here.\n # registeredRegions is a DynamoDB String Set (SS). `ADD` on a set is atomic and idempotent\n # (a no-op if the region is already present), so concurrent/retried registrations for the\n # same buyer + region can never duplicate an entry — no read-then-write, no get_item.\n # Stamp the SET clause FIRST, then append the ADD clause (audit stamps are SET assignments\n # and must not land after an ADD).\n update_expr = \"SET productCode = :pc\"\n values = {\":pc\": product_code}\n update_expr, values = _stamp(update_expr, values)\n if INVOCATION_REGION:\n update_expr += \" ADD registeredRegions :region\"\n values[\":region\"] = {INVOCATION_REGION} # Python set -> DynamoDB String Set\n\n subscribers_table.update_item(\n Key={\"licenseArn\": license_arn, \"customerAWSAccountId\": customer_aws_account_id},\n UpdateExpression=update_expr,\n ExpressionAttributeValues=values,\n )\n\n logger.info(\n f\"Registered customer account={_mask(customer_aws_account_id)} region={INVOCATION_REGION} \"\n f\"(PII → in-region customer-profile table; subscribers row PII-free)\"\n )\n\n return _response(\n 200,\n {\"message\": \"Registration successful. Your account is being activated.\"},\n )\n\n\ndef _extract_registration_fields(form):\n \"\"\"Extract ALLOWLISTED custom registration form fields.\n\n The registration endpoint is public and unauthenticated, so we do NOT persist\n arbitrary caller-supplied fields. We persist only the fields named in the\n ALLOWED_REGISTRATION_FIELDS env var (comma-separated), and enforce per-field length\n and total field-count bounds. Unknown/oversized fields are dropped, not written\n verbatim to DynamoDB. The token is never persisted.\n\n TODO(seller): configure ALLOWED_REGISTRATION_FIELDS to match your registration form,\n e.g. \"company_name,email,team_size\". If unset, no custom fields are persisted.\n \"\"\"\n registration_data: dict[str, str] = {}\n if not ALLOWED_REGISTRATION_FIELDS:\n return registration_data\n count = 0\n for key in ALLOWED_REGISTRATION_FIELDS:\n if count >= MAX_REGISTRATION_FIELDS:\n break\n values = form.get(key)\n if not values:\n continue\n value = values[0] # single-valued only; ignore repeated params\n if not isinstance(value, str):\n continue\n if len(value) > MAX_FIELD_VALUE_LENGTH:\n logger.warning(f\"Registration field '{key}' exceeds max length; truncating\")\n value = value[:MAX_FIELD_VALUE_LENGTH]\n registration_data[key] = value\n count += 1\n return registration_data\n\n\ndef _response(status, body):\n return {\n \"statusCode\": status,\n \"headers\": {\n \"Content-Type\": \"application/json\",\n \"Cache-Control\": \"no-store\",\n \"X-Content-Type-Options\": \"nosniff\",\n \"X-Frame-Options\": \"DENY\",\n \"Strict-Transport-Security\": \"max-age=31536000; includeSubDomains\",\n },\n \"body\": json.dumps(body),\n }\n","content_type":"text/x-python; charset=utf-8","language":"python","size":12996,"content_sha256":"335bd8541a821539becb3c3b05e4fbf65fa37f49f8c0526973f6edafd3632dd4"},{"filename":"scripts/submitter.py","content":"\"\"\"Submitter Lambda.\n\nEventBridge-scheduled with ReservedConcurrentExecutions=1 (a single serial submitter — the\nsafe Option A posture vs. the BatchMeterUsage request-rate limit). It:\n\n 1. Reads pending aggregated records from aggregated_usage via its metering_pending GSI\n (each record is ONE already-aggregated (licenseArn, account, dimension, hour) unit).\n 2. Coalesces up to 25 UsageRecords per BatchMeterUsage call, split into Concurrent\n Agreements (LicenseArn) and legacy (ProductCode) batches; retries UnprocessedRecords\n once; isolates a request-level poison record by bisecting.\n 3. Writes MeteringRecordId + meteringStatus (+ reason) and REMOVEs meteringPending ON THE\n aggregated_usage record (NOT the raw usage table).\n 4. Submits any 'deprovisioning' license's records FIRST (deprovisioning-first ordering) so its\n final usage is flushed within the ~1h window. It does NOT finalize the subscriber — flipping\n subscriptionStatus to 'inactive' is done by the separate events-stack deprovision-cleanup\n Lambda once the deprovisioningExpiry window has elapsed (see the handler note below and\n scripts/deprovision_cleanup.py), because a license may span multiple hours/regions and no\n single submitter run can know it is fully drained.\n\nIdempotency: BatchMeterUsage is first-write-wins deduped per\n(CustomerAWSAccountId + LicenseArn + dimension + hour) per region, so a re-submitted\nidentical record is not double-billed. A record whose write-back was lost keeps\nmeteringPending and is re-submitted next run (deduped).\n\"\"\"\n\nimport os\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any, Dict, List, Tuple\n\nfrom handlers import metering_core as core\n\nlogger = core.logger\n\n# Max aggregated records to drain per invocation. reserved=1 is a single serial submitter;\n# at 25 records/BatchMeterUsage call and the ~10 TPS API limit this is ~3000 calls, well\n# within a 5-minute run — so the cap is API-feasible, not an artificial low ceiling.\nMAX_RECORDS_PER_RUN = int(os.environ.get(\"MAX_RECORDS_PER_RUN\", \"50000\"))\n\n# Optional TTL (days) for finalized aggregated_usage rows (TTL config). 0 = disabled\n# (default): the table has no TTL spec, so we never set `ttl`. When >0, set `ttl` ONLY on\n# terminal Success rows so pending/failed rows are never auto-expired.\nAGGREGATED_USAGE_TTL_DAYS = int(os.environ.get(\"AGGREGATED_USAGE_TTL_DAYS\", \"0\"))\n\n# MeteringMode decides prod (live) vs non-prod (dry-run). In dry-run the submitter runs the\n# ENTIRE pipeline — read, build batches, deprovisioning-first ordering — but NEVER calls\n# BatchMeterUsage/MeterUsage: it logs + EMF-emits the record(s) it WOULD have sent and writes\n# meteringStatus=DryRunSubmitted. This lets a non-prod stage exercise everything without ever\n# billing a real buyer. `live` is the only mode that calls the real API.\nMETERING_MODE = os.environ.get(\"METERING_MODE\", \"live\").strip().lower()\nDRY_RUN = METERING_MODE == \"dry-run\"\nSTATUS_DRY_RUN = \"DryRunSubmitted\"\n\n\ndef _dry_run_submit(batch, keys, outcomes, use_product_code=False):\n \"\"\"Dry-run stand-in for core.submit_records: NEVER calls BatchMeterUsage. Records the\n would-be submission as DryRunSubmitted (masked) and emits a metric. Returns False\n (nothing is ever 'unprocessed' in dry-run).\"\"\"\n for i, rec in enumerate(batch):\n gk = keys[i]\n outcomes[gk] = {\"MeteringRecordId\": None, \"Status\": STATUS_DRY_RUN}\n logger.info(\n \"DRY-RUN (MeteringMode=dry-run) — would submit UsageRecord: \"\n f\"license={core.mask(rec.get('LicenseArn', ''))} \"\n f\"account={core.mask(rec.get('CustomerAWSAccountId', ''))} \"\n f\"dimension={rec.get('Dimension', '')} quantity={rec.get('Quantity')} \"\n f\"(use_product_code={use_product_code}); NOT calling BatchMeterUsage\"\n )\n core.emf_metric(\"DryRunSubmitted\", len(batch))\n return False\n\n\ndef handler(event, context):\n if core.aggregated_usage_table is None:\n raise RuntimeError(\"AGGREGATED_USAGE_TABLE not configured\")\n\n now = datetime.now(timezone.utc)\n\n # Deprovisioning-first: read the deprovisioning set's pending records SEPARATELY and submit\n # them AHEAD of the ordinary backlog, so a closing-window license is not delayed behind a\n # large oldest-first backlog (and is not lost to the MAX_RECORDS_PER_RUN cap on the regular\n # read). Best-effort: if the deprovisioning-index lookup fails (throttle/transient/misconfig)\n # we log and proceed with the ordinary pass only — a prioritization lookup failure must NOT\n # block ordinary metering submission.\n try:\n deprovisioning = core.deprovisioning_licenses()\n except Exception:\n logger.warning(\n \"deprovisioning_licenses() lookup failed; submitting ordinary backlog only \"\n \"(deprovisioning records still submit via the regular pass)\",\n exc_info=True,\n )\n deprovisioning = set()\n\n pending: Dict[Tuple[str, str], Dict[str, Any]] = {}\n if deprovisioning:\n pending.update(_read_deprovisioning_pending(now, deprovisioning))\n # Regular oldest-first pass fills the remainder up to the cap (skips keys already read).\n _read_pending_aggregated(now, into=pending)\n if not pending:\n logger.info(\"No pending aggregated usage to submit\")\n return {\"submitted\": 0}\n\n ca_batches, legacy_batches = _build_batches(pending, deprovisioning)\n\n outcomes: Dict[Any, Dict[str, Any]] = {}\n unprocessed_remaining = False\n _submit = _dry_run_submit if DRY_RUN else core.submit_records\n for batch, keys in ca_batches:\n unprocessed_remaining |= _submit(batch, keys, outcomes, use_product_code=False)\n for batch, keys in legacy_batches:\n unprocessed_remaining |= _submit(batch, keys, outcomes, use_product_code=True)\n\n for agg_key, outcome in outcomes.items():\n _write_back(pending[agg_key], outcome)\n\n # NOTE: the submitter does NOT flip subscriptionStatus to inactive. A deprovisioning\n # license may have usage across multiple hours AND multiple regions (one submitter per\n # region, one shared subscribers table), so no single submitter run can know the license\n # is fully drained. Finalization (deprovisioning -> inactive + clearing the deprovisioning\n # markers) is owned solely by the events-stack cleanup Lambda, which fires once the ~1h\n # flush window (deprovisioningExpiry) has elapsed — after which no region can meter it.\n # The submitter's only deprovisioning role is prioritization (deprovisioning records first).\n\n logger.info(\n f\"Submitted {len(outcomes)} aggregated record(s) across \"\n f\"{len(set(k[0] for k in outcomes))} license(s)\"\n )\n\n if unprocessed_remaining:\n raise RuntimeError(\n \"One or more UsageRecords remained unprocessed after retry; their \"\n \"aggregated_usage meteringPending markers were kept for re-submission.\"\n )\n return {\"submitted\": len(outcomes)}\n\n\ndef _read_deprovisioning_pending(now, deprovisioning) -> Dict[Tuple[str, str], Dict[str, Any]]:\n \"\"\"Read pending aggregated records for the deprovisioning set, across the full window\n INCLUDING the current in-progress hour (offset 0..23), via a TARGETED per-license query on\n the aggregated_usage metering_pending GSI (`meteringPending = :hb AND licenseArn = :la` —\n the GSI RANGE key). Returns ALL of them\n (NOT subject to MAX_RECORDS_PER_RUN) so a closing-window license is never left behind the\n ordinary oldest-first backlog or dropped by the regular read cap. O(deprovisioning-records).\"\"\"\n out: Dict[Tuple[str, str], Dict[str, Any]] = {}\n base = now.replace(minute=0, second=0, microsecond=0)\n # Include the CURRENT hour (offset 0) as well as now-1h..now-23h: the flush-deprovisioning\n # sweep enqueues a deprovisioning license's current-hour groups near its window close, so a\n # current-hour aggregated record can be pending and MUST be submitted before the window\n # closes. (The ORDINARY pass never reads the in-progress current hour; the deprovisioning\n # pass does, because for a deprovisioning license the window can close inside it.)\n for hours_back in range(23, -1, -1):\n hour_bucket = (base - timedelta(hours=hours_back)).strftime(core.HOUR_FMT)\n for license_arn in deprovisioning:\n kwargs: Dict[str, Any] = {\n \"IndexName\": \"metering_pending\",\n \"KeyConditionExpression\": \"meteringPending = :hb AND licenseArn = :la\",\n \"ExpressionAttributeValues\": {\":hb\": hour_bucket, \":la\": license_arn},\n }\n while True:\n response = core.aggregated_usage_table.query(**kwargs)\n for item in response.get(\"Items\", []):\n out[(item[\"licenseArn\"], item[\"account_dimension_hour\"])] = item\n if \"LastEvaluatedKey\" not in response:\n break\n kwargs[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n return out\n\n\ndef _read_pending_aggregated(now, into=None) -> Dict[Tuple[str, str], Dict[str, Any]]:\n \"\"\"Read pending aggregated records via the aggregated_usage metering_pending GSI over\n the submittable window ``now-23h … now-1h``, OLDEST-first.\n\n Oldest-first is deliberate: an offset-23 record is closest to falling out of the 24h\n BatchMeterUsage window, so it is drained first. Fills up to MAX_RECORDS_PER_RUN total per\n run so the backlog flushes fast; ``into`` (the already-read deprovisioning-first records)\n is preserved and counted toward the cap, and its keys are not re-read. Records that DID age\n past 24h are handled by the separate scheduled expiry Lambda over aggregated_usage — NOT\n here.\"\"\"\n pending: Dict[Tuple[str, str], Dict[str, Any]] = into if into is not None else {}\n base = now.replace(minute=0, second=0, microsecond=0)\n for hours_back in range(23, 0, -1): # 23 (oldest) .. 1 (newest completed hour)\n hour_bucket = (base - timedelta(hours=hours_back)).strftime(core.HOUR_FMT)\n kwargs: Dict[str, Any] = {\n \"IndexName\": \"metering_pending\",\n \"KeyConditionExpression\": \"meteringPending = :hb\",\n \"ExpressionAttributeValues\": {\":hb\": hour_bucket},\n }\n while True:\n response = core.aggregated_usage_table.query(**kwargs)\n for item in response.get(\"Items\", []):\n key = (item[\"licenseArn\"], item[\"account_dimension_hour\"])\n if key not in pending: # keep the deprovisioning-first read; don't overwrite\n pending[key] = item\n if len(pending) >= MAX_RECORDS_PER_RUN:\n return pending\n if \"LastEvaluatedKey\" not in response:\n break\n kwargs[\"ExclusiveStartKey\"] = response[\"LastEvaluatedKey\"]\n return pending\n\n\ndef _build_batches(pending, deprovisioning_licenses=None):\n \"\"\"Build BatchMeterUsage records from aggregated rows, split into CA and legacy \u003c=25\n batches. Each aggregated row is already validated + merged by the aggregator, so the\n submitter does not re-validate; it only shapes the API records.\n\n Records for a license currently in its deprovisioning flush window\n (``deprovisioning_licenses``, queried once from the sparse deprovisioning-pending-index)\n are ordered FIRST so a closing-window license is drained ahead of ordinary backlog; within\n each tier the existing oldest-first ordering of ``pending`` (now-23h -> now-1h) is preserved\n (dict insertion order). This only reorders which records fill the first BatchMeterUsage\n batches — it does not change what is submitted, the dedup, or the cadence.\"\"\"\n deprovisioning_licenses = deprovisioning_licenses or set()\n # Deprovisioning-first, otherwise preserve insertion (oldest-first) order.\n ordered_items = sorted(\n pending.items(),\n key=lambda kv: 0 if kv[1].get(\"licenseArn\") in deprovisioning_licenses else 1,\n )\n\n ca_records, ca_keys = [], []\n legacy_records, legacy_keys = [], []\n\n for agg_key, item in ordered_items:\n license_arn = item[\"licenseArn\"]\n account_id = item.get(\"customerAWSAccountId\", \"\")\n dimension = item[\"dimension\"]\n quantity = core.strict_int(item.get(\"quantity\", 0)) or 0\n hour_ts = datetime.strptime(item[\"hourBucket\"], core.HOUR_FMT).replace(\n tzinfo=timezone.utc\n )\n\n record: Dict[str, Any] = {\n \"Timestamp\": hour_ts,\n \"Dimension\": dimension,\n \"Quantity\": quantity,\n }\n allocations = item.get(\"usageAllocations\")\n if allocations:\n record[\"UsageAllocations\"] = _to_api_allocations(allocations)\n\n if license_arn.startswith(\"arn:\"):\n record[\"CustomerAWSAccountId\"] = account_id\n record[\"LicenseArn\"] = license_arn\n ca_records.append(record)\n ca_keys.append(agg_key)\n else:\n record[\"CustomerIdentifier\"] = item.get(\"customerIdentifier\", \"\")\n legacy_records.append(record)\n legacy_keys.append(agg_key)\n\n return _chunk(ca_records, ca_keys), _chunk(legacy_records, legacy_keys)\n\n\ndef _to_api_allocations(allocations) -> List[Dict[str, Any]]:\n \"\"\"Normalize stored usageAllocations to the BatchMeterUsage shape (ints, not Decimal).\"\"\"\n out = []\n for alloc in allocations:\n out.append(\n {\n \"AllocatedUsageQuantity\": core.strict_int(alloc.get(\"AllocatedUsageQuantity\")) or 0,\n \"Tags\": [\n {\"Key\": t[\"Key\"], \"Value\": t[\"Value\"]} for t in alloc.get(\"Tags\", [])\n ],\n }\n )\n return out\n\n\ndef _chunk(records, keys):\n return [\n (records[i : i + core.BATCH_SIZE], keys[i : i + core.BATCH_SIZE])\n for i in range(0, len(records), core.BATCH_SIZE)\n ]\n\n\ndef _write_back(item, outcome):\n \"\"\"Persist MeteringRecordId + Status (+ reason) and REMOVE meteringPending on the\n aggregated_usage record. Not written to the raw usage table.\"\"\"\n status = outcome.get(\"Status\", \"\")\n metering_record_id = outcome.get(\"MeteringRecordId\")\n reason = outcome.get(\"Reason\")\n\n update = \"REMOVE meteringPending SET meteringStatus = :st\"\n values: Dict[str, Any] = {\":st\": status}\n if metering_record_id:\n update += \", meteringRecordId = :mid\"\n values[\":mid\"] = metering_record_id\n if reason:\n update += \", meteringStatusReason = :rsn\"\n values[\":rsn\"] = reason\n # Set a TTL epoch ONLY on a terminal Success row when retention is configured (>0), so\n # a finalized record self-prunes but pending/failed rows are never auto-expired.\n if AGGREGATED_USAGE_TTL_DAYS > 0 and status == \"Success\":\n ttl_epoch = int(\n (datetime.now(timezone.utc).timestamp()) + AGGREGATED_USAGE_TTL_DAYS * 86400\n )\n update += \", #ttl = :ttl\"\n values[\":ttl\"] = ttl_epoch\n\n update, values, _ = core.with_audit_timestamps(update, values)\n kwargs: Dict[str, Any] = {\n \"Key\": {\n \"licenseArn\": item[\"licenseArn\"],\n \"account_dimension_hour\": item[\"account_dimension_hour\"],\n },\n \"UpdateExpression\": update,\n \"ExpressionAttributeValues\": values,\n }\n # `ttl` is not a DynamoDB reserved word, but use a name placeholder for safety/clarity.\n if AGGREGATED_USAGE_TTL_DAYS > 0 and status == \"Success\":\n kwargs[\"ExpressionAttributeNames\"] = {\"#ttl\": \"ttl\"}\n\n core.aggregated_usage_table.update_item(**kwargs)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":15602,"content_sha256":"64608ac3fc033b4b5a1dafeb60e370bf6fd93de055776f373d15f26fed376509"},{"filename":"scripts/subscription.py","content":"import json\nimport logging\nimport os\nfrom datetime import datetime, timedelta, timezone\n\nimport boto3\n\nlogger = logging.getLogger()\nlogger.setLevel(logging.INFO)\n\ndynamodb = boto3.resource(\"dynamodb\")\nsubscribers_table = dynamodb.Table(os.environ[\"SUBSCRIBERS_TABLE\"])\n\n# Sparse deprovisioning index markers. On License Deprovisioned the handler writes a constant\n# `deprovisioningPendingFlag` (HASH) and a `deprovisioningExpiry` (RANGE) = event time + the\n# flush window, so the events-stack cleanup Lambda can Query flag='1' AND expiry\u003c=now to\n# finalize expired licenses, while the main-stack sweep/submitter Query flag='1' for the\n# active set. Kept in sync with metering_core.DEPROVISIONING_PENDING_VALUE (subscription.py\n# runs in the events stack and does not import metering_core).\nDEPROVISIONING_PENDING_VALUE = \"1\"\nDEPROVISIONING_FLUSH_WINDOW = timedelta(hours=1) # AWS Marketplace ~1h post-deprovision window\n\n\ndef _mask(value):\n \"\"\"Mask a sensitive identifier for logging: keep only the last 4 chars. Buyer account IDs /\n license ARNs are sensitive and MUST NOT be logged in full (mirrors register.py._mask).\"\"\"\n if not value:\n return \"\u003cnone>\"\n s = str(value)\n return \"****\" + s[-4:] if len(s) > 4 else \"****\"\n\n\ndef _stamp(update_expr, values):\n \"\"\"Append createdAt(once)/updatedAt audit stamps to a SET UpdateExpression.\n\n The stamps are SET assignments, so callers MUST stamp while the expression is still a pure\n SET clause and append any ADD/REMOVE/DELETE clause AFTERWARDS — appending the stamps after an\n ADD clause corrupts the expression (DynamoDB rejects ``... ADD s :r, createdAt = ...``). All\n callers here pass pure-SET expressions. (Events-stack twin of register.py's ``_stamp``.)\n \"\"\"\n values = dict(values)\n values[\":now\"] = datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n return (\n f\"{update_expr}, createdAt = if_not_exists(createdAt, :now), updatedAt = :now\",\n values,\n )\n\n\ndef handler(event, context):\n \"\"\"Process EventBridge marketplace events from SQS into the unified subscribers table.\n\n Returns partial batch failures so only the failed message is retried (requires the\n event source mapping to set FunctionResponseTypes: ReportBatchItemFailures).\n \"\"\"\n batch_item_failures = []\n for record in event.get(\"Records\", []):\n try:\n body = json.loads(record[\"body\"])\n except (json.JSONDecodeError, KeyError):\n logger.error(\"Invalid SQS message body, skipping (not retried)\")\n continue\n\n try:\n if \"detail-type\" in body:\n _process_event(body)\n else:\n logger.warning(\"Unknown message format, skipping\")\n except Exception:\n logger.exception(\"Failed to process record\")\n batch_item_failures.append({\"itemIdentifier\": record.get(\"messageId\", \"\")})\n\n return {\"batchItemFailures\": batch_item_failures}\n\n\ndef _process_event(event):\n \"\"\"Route an EventBridge event to the appropriate handler.\n\n The subscriber row carries TWO independent status fields that this handler owns and\n MUST NOT conflate:\n\n * agreementStatus (active | inactive) — the AGREEMENT lifecycle.\n * subscriptionStatus (active | deprovisioning | inactive) — the LICENSE lifecycle.\n\n Metering DECISIONS are driven only by the license lifecycle:\n * `License Updated` -> access granted/refreshed (agreement + subscription active).\n * `License Deprovisioned`-> access revoked; opens the ~1-hour final-usage flush\n window (subscriptionStatus = deprovisioning + a\n deprovisioningExpiry). The events-stack deprovision-cleanup\n Lambda sets it inactive once the window has elapsed.\n\n `Purchase Agreement Ended` and `Purchase Agreement Amended` update AGREEMENT status /\n metadata ONLY — they do NOT stop metering or trigger a flush. `Purchase Agreement\n Created` is intentionally NOT consumed (it carries no licenseArn and is not needed to\n meter); sellers who need it add their own rule/target — see\n references/architecture.md (\"Purchase Agreement Created — seller use cases\").\n \"\"\"\n detail_type = event.get(\"detail-type\", \"\")\n detail = event.get(\"detail\", {})\n logger.info(f\"EventBridge event: {detail_type}\")\n\n if \"License Updated\" in detail_type:\n _handle_license_updated(detail)\n elif \"License Deprovisioned\" in detail_type:\n _handle_license_deprovisioned(detail, event.get(\"time\"))\n elif \"Purchase Agreement Ended\" in detail_type:\n _handle_agreement_ended(detail)\n elif \"Purchase Agreement Amended\" in detail_type:\n _handle_agreement_amended(detail)\n elif \"Purchase Agreement Created\" in detail_type:\n # Not consumed by the metering path (no licenseArn; not needed to meter).\n # Logged only for observability. See references/architecture.md for how sellers\n # can use this event for other use cases (pre-provisioning, CRM sync, etc.).\n logger.info(\"Purchase Agreement Created received; not used by metering path (ignored)\")\n\n\ndef _handle_license_updated(detail):\n \"\"\"`License Updated` — the canonical event that provides the LicenseArn and\n establishes/refreshes the buyer's access. Upsert the subscriber row (keyed by the\n real licenseArn) and set BOTH agreementStatus and subscriptionStatus to active.\n \"\"\"\n acceptor_account_id = detail.get(\"acceptor\", {}).get(\"accountId\", \"\")\n agreement_id = detail.get(\"agreement\", {}).get(\"id\", \"\")\n license_arn = detail.get(\"license\", {}).get(\"arn\", \"\")\n product_code = detail.get(\"product\", {}).get(\"code\", \"\")\n\n if not acceptor_account_id or not license_arn:\n logger.error(\"Missing acceptorAccountId or licenseArn in License Updated event\")\n return\n\n key = {\"licenseArn\": license_arn, \"customerAWSAccountId\": acceptor_account_id}\n metadata_values = {\":pc\": product_code, \":aid\": agreement_id, \":active\": \"active\"}\n\n # EventBridge delivery is at-least-once and may be reordered, so a late/duplicate\n # `License Updated` could arrive AFTER `License Deprovisioned`. Only move\n # subscriptionStatus to `active` when it is absent or already `active`, so we never\n # resurrect a `deprovisioning`/`inactive` license and re-open metering for a revoked\n # buyer. agreementStatus + metadata are always refreshed.\n try:\n _mv = dict(metadata_values)\n _mv[\":now\"] = datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n subscribers_table.update_item(\n Key=key,\n UpdateExpression=(\n \"SET productCode = :pc, agreementId = :aid, \"\n \"agreementStatus = :active, subscriptionStatus = :active, \"\n \"createdAt = if_not_exists(createdAt, :now), updatedAt = :now\"\n ),\n ConditionExpression=(\n \"attribute_not_exists(subscriptionStatus) OR subscriptionStatus = :active\"\n ),\n ExpressionAttributeValues=_mv,\n )\n except subscribers_table.meta.client.exceptions.ConditionalCheckFailedException:\n # Row is deprovisioning/inactive — refresh agreement metadata but do NOT reactivate.\n subscribers_table.update_item(\n Key=key,\n UpdateExpression=(\n \"SET productCode = :pc, agreementId = :aid, agreementStatus = :active, \"\n \"createdAt = if_not_exists(createdAt, :now), updatedAt = :now\"\n ),\n ExpressionAttributeValues=_mv,\n )\n logger.warning(\n \"License Updated arrived for a non-active subscription \"\n f\"(account={_mask(acceptor_account_id)} licenseArn={_mask(license_arn)}); refreshed agreement \"\n \"metadata but left subscriptionStatus unchanged (not reactivating a revoked license).\"\n )\n return\n logger.info(f\"License updated (active): account={_mask(acceptor_account_id)} licenseArn={_mask(license_arn)}\")\n\n\ndef _deprovisioning_expiry(event_time):\n \"\"\"ISO-8601 UTC timestamp when the ~1h flush window closes: event time + window.\n\n Uses the EventBridge event `time` when available (the authoritative moment the license\n was deprovisioned), else falls back to now. The events-stack cleanup Lambda finalizes a\n license once this instant has passed.\n \"\"\"\n base = None\n if event_time:\n try:\n base = datetime.fromisoformat(str(event_time).replace(\"Z\", \"+00:00\"))\n except ValueError:\n base = None\n if base is None:\n base = datetime.now(timezone.utc)\n if base.tzinfo is None:\n base = base.replace(tzinfo=timezone.utc)\n return (base + DEPROVISIONING_FLUSH_WINDOW).astimezone(timezone.utc).strftime(\n \"%Y-%m-%dT%H:%M:%SZ\"\n )\n\n\ndef _handle_license_deprovisioned(detail, event_time=None):\n \"\"\"`License Deprovisioned` — access revoked. Opens the ~1-hour final-usage flush window.\n\n Sets subscriptionStatus = deprovisioning and marks the row in the sparse\n deprovisioning-pending-index (constant `deprovisioningPendingFlag` HASH + a\n `deprovisioningExpiry` = event time + ~1h RANGE). During the window the main-stack\n expedited sweep + submitter flush this license immediately (bypassing MeteringLockHours).\n Once `deprovisioningExpiry` has passed, the events-stack cleanup Lambda finalizes the\n license (deprovisioning -> inactive) and removes both markers. Finalization is NOT done by\n the submitter, because a license may have usage across multiple hours and regions and no\n single submitter run can know it is fully drained.\n\n Reference:\n https://docs.aws.amazon.com/marketplace/latest/userguide/saas-eventbridge-integration.html\n \"\"\"\n acceptor_account_id = detail.get(\"acceptor\", {}).get(\"accountId\", \"\")\n license_arn = detail.get(\"license\", {}).get(\"arn\", \"\")\n\n if not license_arn:\n agreement_id = detail.get(\"agreement\", {}).get(\"id\", \"\")\n license_arn = _resolve_license_arn(agreement_id)\n\n if not acceptor_account_id or not license_arn:\n logger.error(\"Cannot resolve subscriber for License Deprovisioned event\")\n return\n\n expiry = _deprovisioning_expiry(event_time)\n _dep_expr, _dep_vals = _stamp(\n \"SET subscriptionStatus = :s, deprovisioningPendingFlag = :f, deprovisioningExpiry = :exp\",\n {\":s\": \"deprovisioning\", \":f\": DEPROVISIONING_PENDING_VALUE, \":exp\": expiry},\n )\n subscribers_table.update_item(\n Key={\n \"licenseArn\": license_arn,\n \"customerAWSAccountId\": acceptor_account_id,\n },\n UpdateExpression=_dep_expr,\n ExpressionAttributeValues=_dep_vals,\n )\n logger.info(\n \"License deprovisioned (final-usage flush window open until \"\n f\"{expiry}): account={_mask(acceptor_account_id)} licenseArn={_mask(license_arn)} \"\n \"subscriptionStatus=deprovisioning\"\n )\n\n\ndef _handle_agreement_ended(detail):\n \"\"\"`Purchase Agreement Ended` — a STATUS update only. Set agreementStatus = inactive.\n\n This does NOT change subscriptionStatus, stop metering, or trigger a flush: the buyer\n may still be entitled to usage until the license is deprovisioned. The flush window\n is opened by `License Deprovisioned`, not by this event.\n \"\"\"\n acceptor_account_id = detail.get(\"acceptor\", {}).get(\"accountId\", \"\")\n license_arn = detail.get(\"license\", {}).get(\"arn\", \"\")\n\n if not license_arn:\n agreement_id = detail.get(\"agreement\", {}).get(\"id\", \"\")\n license_arn = _resolve_license_arn(agreement_id)\n\n if not acceptor_account_id or not license_arn:\n logger.error(\"Cannot resolve subscriber for Purchase Agreement Ended event\")\n return\n\n _end_expr, _end_vals = _stamp(\"SET agreementStatus = :inactive\", {\":inactive\": \"inactive\"})\n subscribers_table.update_item(\n Key={\n \"licenseArn\": license_arn,\n \"customerAWSAccountId\": acceptor_account_id,\n },\n UpdateExpression=_end_expr,\n ExpressionAttributeValues=_end_vals,\n )\n logger.info(\n \"Purchase Agreement Ended (agreement status only; metering unaffected): \"\n f\"account={_mask(acceptor_account_id)} licenseArn={_mask(license_arn)} agreementStatus=inactive\"\n )\n\n\ndef _handle_agreement_amended(detail):\n \"\"\"`Purchase Agreement Amended` — update agreement metadata only (does not change\n subscriptionStatus or stop metering). The agreement remains active on an amendment.\n \"\"\"\n acceptor_account_id = detail.get(\"acceptor\", {}).get(\"accountId\", \"\")\n agreement = detail.get(\"agreement\", {})\n license_arn = detail.get(\"license\", {}).get(\"arn\", \"\")\n\n if not license_arn:\n agreement_id = agreement.get(\"id\", \"\")\n license_arn = _resolve_license_arn(agreement_id)\n\n if not acceptor_account_id or not license_arn:\n logger.error(\"Cannot resolve subscriber for Purchase Agreement Amended event\")\n return\n\n _amd_expr, _amd_vals = _stamp(\n \"SET endTime = :et, agreementStatus = :active\",\n {\":et\": agreement.get(\"endTime\", \"\"), \":active\": \"active\"},\n )\n subscribers_table.update_item(\n Key={\n \"licenseArn\": license_arn,\n \"customerAWSAccountId\": acceptor_account_id,\n },\n UpdateExpression=_amd_expr,\n ExpressionAttributeValues=_amd_vals,\n )\n logger.info(f\"Agreement amended: account={_mask(acceptor_account_id)} licenseArn={_mask(license_arn)}\")\n\n\ndef _resolve_license_arn(agreement_id):\n \"\"\"Resolve licenseArn from agreementId via the agreementId GSI (Query, NOT a Scan),\n used when an Agreement Ended/Amended/Deprovisioned event omits license.arn.\n\n A genuinely-missing row returns \"\" (the caller logs and the message is retried/DLQ'd),\n but permission/config errors (AccessDenied etc.) are NOT swallowed as \"not found\" —\n they propagate so the handler's broad except records a batch-item failure (retry ->\n DLQ alarm) rather than silently leaving the subscriber unchanged.\n \"\"\"\n if not agreement_id:\n return \"\"\n\n response = subscribers_table.query(\n IndexName=\"agreementId-index\",\n KeyConditionExpression=\"agreementId = :agr\",\n ExpressionAttributeValues={\":agr\": agreement_id},\n Limit=1,\n )\n items = response.get(\"Items\", [])\n if items:\n return items[0].get(\"licenseArn\", \"\")\n return \"\"\n","content_type":"text/x-python; charset=utf-8","language":"python","size":14483,"content_sha256":"18e6d59af0ae39770b075bd7294ef0036afa2e820506aefd3caf02ae96303671"},{"filename":"scripts/test_event_publisher.py","content":"\"\"\"Test event publisher — NON-PRODUCTION / dry-run stages ONLY.\n\nGenerates EventBridge events whose ``detail`` shape EXACTLY matches AWS Marketplace subscription\nevents (the detail-types the subscription Lambda consumes) with RANDOMIZED but VALID field\nvalues, so a seller can exercise the full metering integration in a sandbox stage without any\nreal Marketplace subscription. It is a TEST SIMULATOR: the events it emits are NOT real\nsubscriptions and confer NO entitlements — they only drive the seller's own non-prod pipeline.\n\nEvents are emitted under a STAGE-SCOPED source (``\u003cstageName>.agreement-marketplace``), NEVER the\nreserved AWS service source ``aws.agreement-marketplace``: (1) EventBridge reserves the ``aws.``\nsource prefix for real AWS-service events, so a custom PutEvents with an ``aws.`` source is not\nreliably delivered to rules; (2) a stage-scoped source is DISJOINT from the real production\nsource, so a same-account non-prod stage's test events can never be matched (double-written) by\nthe production rule. An ``aws.*`` source is REJECTED (no default).\n\nGuardrails (must be enforced by the deploying stack / caller):\n * Deploy ONLY in a stage whose MeteringMode is ``dry-run`` (the submitter then never calls the\n real BatchMeterUsage). NEVER deploy this in a ``live`` stage.\n * ``acceptor.accountId`` is drawn ONLY from the seller-declared TEST_ACCOUNT_ALLOWLIST — never\n a real/production buyer account.\n * The event source is stage-scoped (never ``aws.*``); the productCode is supplied PER INVOCATION\n (payload ``productCode``) so the SHARED events stack carries no per-product value.\n * Publish to the same account/region as the non-prod events stack so its EventBridge rule\n (scoped to the stage source + test accounts) picks the events up.\n\nScenarios (``--scenario`` / event ``scenario`` field): new, deprovision, agreement-ended,\nagreement-amended, multi-region, multi-dimension, burst, malformed, month-boundary, duplicate.\n\"\"\"\n\nimport argparse\nimport json\nimport os\nimport random\nimport string\nimport uuid\nfrom datetime import datetime, timedelta, timezone\n\n# The reserved AWS service source — the test publisher must NOT emit under this (see module\n# docstring); it is referenced only to REJECT it. There is deliberately no default source.\nRESERVED_SOURCE_PREFIX = \"aws.\"\n\n# Detail-types the subscription Lambda consumes (must match production exactly).\nDETAIL_TYPES = {\n \"new\": \"License Updated\",\n \"deprovision\": \"License Deprovisioned\",\n \"agreement-ended\": \"Purchase Agreement Ended\",\n \"agreement-amended\": \"Purchase Agreement Amended\",\n}\n\nSCENARIOS = [\n \"new\",\n \"deprovision\",\n \"agreement-ended\",\n \"agreement-amended\",\n \"multi-region\",\n \"multi-dimension\",\n \"burst\",\n \"malformed\",\n \"month-boundary\",\n \"duplicate\",\n]\n\n# A small pool of well-formed sample regions/dimensions for randomized-but-valid values.\n_SAMPLE_REGIONS = [\"us-east-1\", \"us-west-2\", \"eu-west-1\", \"ap-southeast-2\"]\n_SAMPLE_DIMENSIONS = [\"Requests\", \"GBHours\", \"Users\", \"DataProcessedGB\"]\n\n\ndef _rand_account_id():\n \"\"\"A syntactically valid 12-digit AWS account id (for shape only).\"\"\"\n return \"\".join(random.choices(string.digits, k=12))\n\n\ndef _rand_license_arn(account_id):\n return f\"arn:aws:license-manager::{account_id}:license:l-{uuid.uuid4().hex[:16]}\"\n\n\ndef _rand_agreement_id():\n return \"agmt-\" + uuid.uuid4().hex[:20]\n\n\ndef _iso(dt):\n return dt.strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n\n\ndef _require_event_source(event_source):\n \"\"\"A stage-scoped event source is REQUIRED — there is no default, and an ``aws.*`` source is\n rejected (a custom PutEvents cannot reliably deliver the reserved AWS-service prefix, and it\n would collide with the real production source).\"\"\"\n if not event_source:\n raise ValueError(\"event_source is required (a stage-scoped '\u003cstageName>.agreement-marketplace'); \"\n \"there is no default\")\n if event_source.startswith(RESERVED_SOURCE_PREFIX):\n raise ValueError(f\"event_source '{event_source}' uses the reserved 'aws.' prefix — use a \"\n \"stage-scoped '\u003cstageName>.agreement-marketplace' instead\")\n return event_source\n\n\ndef build_event(scenario, test_accounts, product_code, now=None, event_source=None):\n \"\"\"Build ONE EventBridge event dict in AWS Marketplace shape for the given scenario.\n\n ``test_accounts`` is the REQUIRED non-empty allowlist of test buyer account ids — the\n acceptor is chosen only from it (test accounts only). ``event_source`` is REQUIRED and must be\n a stage-scoped source (never the reserved ``aws.`` prefix); the detail shape matches\n production. Returns the event dict (not yet published).\n \"\"\"\n if not test_accounts:\n raise ValueError(\"test_accounts (TestAccountAllowlist) must be non-empty — non-prod \"\n \"test events may use test accounts ONLY\")\n event_source = _require_event_source(event_source)\n now = now or datetime.now(timezone.utc)\n account_id = random.choice(test_accounts)\n license_arn = _rand_license_arn(account_id)\n agreement_id = _rand_agreement_id()\n\n # Base detail shared by the lifecycle events.\n detail = {\n \"acceptor\": {\"accountId\": account_id},\n \"license\": {\"arn\": license_arn},\n \"agreement\": {\"id\": agreement_id, \"endTime\": _iso(now + timedelta(days=365))},\n \"product\": {\"code\": product_code},\n }\n\n # Map compound scenarios onto a concrete detail-type. These use a License Updated envelope;\n # 'malformed' then corrupts it below, the others vary the USAGE-row side.\n detail_type_key = scenario\n if scenario in (\"multi-region\", \"multi-dimension\", \"burst\", \"duplicate\", \"month-boundary\", \"malformed\"):\n detail_type_key = \"new\"\n\n detail_type = DETAIL_TYPES.get(detail_type_key, \"License Updated\")\n\n if scenario == \"deprovision\":\n # Deprovision opens the ~1h flush window; the subscription Lambda derives expiry from time.\n detail[\"entitlement\"] = {\"status\": \"deprovisioning\"}\n elif detail_type == \"License Updated\":\n detail[\"entitlement\"] = {\"status\": \"active\", \"dimension\": random.choice(_SAMPLE_DIMENSIONS)}\n\n if scenario == \"malformed\":\n # Emit a GENUINELY malformed License Updated event so the non-prod subscription Lambda's\n # bad-input handling is exercised (it should reject/skip and DLQ, not crash): drop the\n # REQUIRED license.arn entirely and corrupt acceptor.accountId to a non-12-digit value.\n detail[\"license\"].pop(\"arn\", None)\n detail[\"acceptor\"][\"accountId\"] = \"not-an-account-id\"\n\n event = {\n \"Source\": event_source,\n \"DetailType\": detail_type,\n \"Detail\": json.dumps(detail),\n # Non-standard helper fields the seeder/consumer may read for the usage-row side; they\n # do NOT appear in real Marketplace events and are ignored by the subscription Lambda.\n \"scenario\": scenario,\n \"time\": _iso(now),\n }\n return event\n\n\ndef build_batch(scenario, test_accounts, product_code, count=1, now=None, event_source=None):\n \"\"\"Build N events for a scenario. ``burst`` implies many; ``duplicate`` repeats one event.\"\"\"\n now = now or datetime.now(timezone.utc)\n if scenario == \"burst\" and count == 1:\n count = 50\n events = [build_event(scenario, test_accounts, product_code, now, event_source) for _ in range(count)]\n if scenario == \"duplicate\" and events:\n # Emit the SAME event twice to exercise idempotency/first-write-wins downstream.\n events.append(json.loads(json.dumps(events[0])))\n return events\n\n\ndef _publish(events, event_bus_name=None, region=None):\n import boto3\n\n client = boto3.client(\"events\", region_name=region) if region else boto3.client(\"events\")\n sent = 0\n for i in range(0, len(events), 10): # PutEvents max 10 entries/call\n chunk = events[i:i + 10]\n entries = []\n for e in chunk:\n entry = {\"Source\": e[\"Source\"], \"DetailType\": e[\"DetailType\"], \"Detail\": e[\"Detail\"]}\n if event_bus_name:\n entry[\"EventBusName\"] = event_bus_name\n entries.append(entry)\n resp = client.put_events(Entries=entries)\n failed = resp.get(\"FailedEntryCount\", 0)\n if failed:\n raise RuntimeError(f\"PutEvents reported {failed} failed entr(ies): {resp}\")\n sent += len(entries)\n return sent\n\n\ndef handler(event, context):\n \"\"\"Lambda entrypoint: generate + publish per the invocation payload.\n\n Invocation payload (the JSON you pass when invoking the Lambda), e.g.::\n\n {\"scenario\": \"new\", \"count\": 3, \"productCode\": \"abcd1234efgh5678\"}\n\n - ``productCode`` (REQUIRED) — the AWS Marketplace product code to stamp on the simulated\n events' ``detail.product.code``. It is supplied PER INVOCATION (NOT a stack parameter or an\n environment variable) on purpose: the events stack is SHARED by every product in the stage,\n so a single deployed publisher can simulate ANY product just by passing a different\n ``productCode`` per invocation — and there is no shared per-product value that a second\n product's deploy could overwrite. How callers pass it:\n * AWS CLI: aws lambda invoke --function-name awsmp-events-\u003cstage>-test-event-publisher \\\\\n --payload '{\"scenario\":\"new\",\"productCode\":\"\u003ccode>\"}' out.json\n * Console: Lambda > Test > event JSON ``{\"scenario\":\"new\",\"productCode\":\"\u003ccode>\"}``\n * The generated test plan / seeder passes the product under test.\n There is NO default and NO env fallback — a missing productCode raises (fail fast) rather\n than silently stamping the wrong/empty product.\n - ``scenario`` (default ``new``) and ``count`` (default 1) select the scenario + how many events.\n\n TEST_ACCOUNT_ALLOWLIST, EVENT_SOURCE, and EVENT_BUS_NAME come from the environment (set by the\n stack only in a dry-run stage).\n \"\"\"\n if os.environ.get(\"METERING_MODE\", \"\").strip().lower() != \"dry-run\":\n raise RuntimeError(\"test_event_publisher may run ONLY in a dry-run (non-prod) stage\")\n test_accounts = [a.strip() for a in os.environ.get(\"TEST_ACCOUNT_ALLOWLIST\", \"\").split(\",\") if a.strip()]\n # productCode is PER INVOCATION (payload) — never a stack param/env. This lets ONE shared\n # publisher simulate any product in the stage with no shared per-product value to overwrite.\n product_code = (event or {}).get(\"productCode\")\n if not product_code:\n raise RuntimeError(\n \"productCode is required in the invocation payload, e.g. \"\n \"{\\\"scenario\\\": \\\"new\\\", \\\"productCode\\\": \\\"\u003cawsMarketplaceProductCode>\\\"}\"\n )\n # Stage-scoped source (e.g. \"beta.agreement-marketplace\"); required, never the reserved \"aws.\" prefix.\n event_source = _require_event_source(os.environ.get(\"EVENT_SOURCE\", \"\").strip())\n scenario = (event or {}).get(\"scenario\", \"new\")\n count = int((event or {}).get(\"count\", 1))\n events = build_batch(scenario, test_accounts, product_code, count, event_source=event_source)\n sent = _publish(events, event_bus_name=os.environ.get(\"EVENT_BUS_NAME\") or None)\n return {\"scenario\": scenario, \"productCode\": product_code, \"published\": sent}\n\n\ndef main():\n p = argparse.ArgumentParser(description=\"Publish AWS-Marketplace-shape TEST events (non-prod only)\")\n p.add_argument(\"--scenario\", choices=SCENARIOS, default=\"new\")\n p.add_argument(\"--count\", type=int, default=1)\n p.add_argument(\"--product-code\", required=True)\n p.add_argument(\"--test-accounts\", required=True,\n help=\"Comma-separated test buyer account ids (allowlist)\")\n p.add_argument(\"--region\")\n p.add_argument(\"--event-bus-name\")\n p.add_argument(\"--event-source\", required=True,\n help=\"Event Source — REQUIRED, a stage-scoped '\u003cstageName>.agreement-marketplace' \"\n \"(the reserved 'aws.' prefix is rejected)\")\n p.add_argument(\"--dry-print\", action=\"store_true\",\n help=\"Print the generated events instead of publishing\")\n args = p.parse_args()\n try:\n _require_event_source(args.event_source)\n except ValueError as e:\n p.error(str(e))\n accounts = [a.strip() for a in args.test_accounts.split(\",\") if a.strip()]\n events = build_batch(args.scenario, accounts, args.product_code, args.count,\n event_source=args.event_source)\n if args.dry_print:\n print(json.dumps(events, indent=2))\n return\n sent = _publish(events, event_bus_name=args.event_bus_name, region=args.region)\n print(f\"Published {sent} '{args.scenario}' test event(s).\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":12752,"content_sha256":"c25fcf0ea7b6a7c9c5372f75bafd882dd0a3b29828e969e4fd7186081a9e0ae9"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"AWS Marketplace Metering","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Overview","type":"text"}]},{"type":"paragraph","content":[{"text":"Complete lifecycle management for AWS Marketplace usage-based metering:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Setup (SaaS PAYG)","type":"text","marks":[{"type":"strong"}]},{"text":" — deploy a new serverless metering integration (SAM, ResolveCustomer, BatchMeterUsage, EventBridge).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Query","type":"text","marks":[{"type":"strong"}]},{"text":" — find, inspect, debug, analyze existing metering submissions (CloudTrail Event History ≤90 days; Seller Reports for billed history).","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Scope is SaaS Usage-based (PAYG). SaaS Contract, Contract with Consumption Pricing (CCP), and AMI/Container are out of scope — redirect to public AWS docs.","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Recommended:","type":"text","marks":[{"type":"strong"}]},{"text":" the AWS MCP server provides sandboxed execution and audit logging.","type":"text"}]},{"type":"paragraph","content":[{"text":"⚠️ ","type":"text"},{"text":"AI-generated output — review before production. This skill is executed by an AI agent and can make mistakes.","type":"text","marks":[{"type":"strong"}]},{"text":" Everything it generates (CloudFormation/SAM templates, Lambda handlers, IAM policies, ","type":"text"},{"text":"deploy.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":") and everything it deploys to a seller's AWS account MUST be treated as a proposal to review, not a trusted artifact. Before any deployed stack carries PRODUCTION metering traffic (real billing), the seller MUST:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Thoroughly review every resource the skill created or changed","type":"text","marks":[{"type":"strong"}]},{"text":" — templates, Lambda code, IAM roles/policies (least-privilege, no unintended grants), tables/indexes, queues, alarms — in their own account, and confirm it matches their intent and their org's standards. Metering errors directly affect revenue (under- or over-billing), so this review is not optional.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Put the generated code through the seller's normal code review + change-management process","type":"text","marks":[{"type":"strong"}]},{"text":" (do not ship AI output straight to production), and","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run the full hands-on test plan","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"references/test-plan.md","type":"text","marks":[{"type":"code_inline"}]},{"text":") end-to-end and verify each expected observable outcome BEFORE relying on the integration for real billing. The skill SHALL surface this review-before-production expectation to the seller, and SHALL NOT imply that a successful deploy means the integration is production-ready without this review + test.","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Using this skill's packaged resources","type":"text"}]},{"type":"paragraph","content":[{"text":"The packaged ","type":"text"},{"text":"scripts/","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"assets/","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"references/","type":"text","marks":[{"type":"code_inline"}]},{"text":" show how to ","type":"text"},{"text":"structure","type":"text","marks":[{"type":"strong"}]},{"text":" a metering stack — a reference, NOT content to copy verbatim.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Get every seller-specific value from the seller","type":"text","marks":[{"type":"strong"}]},{"text":" (product code, region(s), stage name, VMT tags, alarm actions/thresholds, retention/KMS) and fill the ","type":"text"},{"text":"TODO:","type":"text","marks":[{"type":"code_inline"}]},{"text":" markers. If a required value is unknown, ","type":"text"},{"text":"ask","type":"text","marks":[{"type":"strong"}]},{"text":" — do not assume. VMT is presence-based: the seller writes each usage row's ","type":"text"},{"text":"usageAllocations","type":"text","marks":[{"type":"code_inline"}]},{"text":" (their real tags); the pipeline (aggregator) merges them and never invents tags (no ","type":"text"},{"text":"VmtEnabled","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"VmtTagAttribute","type":"text","marks":[{"type":"code_inline"}]},{"text":" parameter).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not change the architecture or ","type":"text","marks":[{"type":"strong"}]},{"text":"awsmp-*","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" naming","type":"text","marks":[{"type":"strong"}]},{"text":" (two-stack layout, table/GSI/event wiring are fixed). Sellers customize only the marked ","type":"text"},{"text":"TODO:","type":"text","marks":[{"type":"code_inline"}]},{"text":" sections. A request that would break the architecture/naming is explained as a constraint, not silently accommodated.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Guardrail — where this skill's own files live (MCP vs local)","type":"text"}]},{"type":"paragraph","content":[{"text":"Before reading a reference or running a script, determine how the skill was loaded:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"AWS MCP ","type":"text","marks":[{"type":"strong"}]},{"text":"retrieve_skill","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":":","type":"text","marks":[{"type":"strong"}]},{"text":" the skill is NOT on the local filesystem — fetch each reference/script via ","type":"text"},{"text":"retrieve_skill","type":"text","marks":[{"type":"code_inline"}]},{"text":" with the ","type":"text"},{"text":"file","type":"text","marks":[{"type":"code_inline"}]},{"text":" parameter (e.g. ","type":"text"},{"text":"file=\"references/deployment-steps.md\"","type":"text","marks":[{"type":"code_inline"}]},{"text":"); do NOT ","type":"text"},{"text":"file_read","type":"text","marks":[{"type":"code_inline"}]},{"text":" these paths.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Installed locally:","type":"text","marks":[{"type":"strong"}]},{"text":" read/run files from the local skill dir via relative paths.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"This applies only to the skill's own files; user data/session artifacts are always in the user's working directory.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Critical Facts (apply to ALL metering operations)","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"CustomerAWSAccountId","type":"text","marks":[{"type":"strong"}]},{"text":" (not CustomerIdentifier) + ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"strong"}]},{"text":" per UsageRecord for new (Concurrent Agreements) products.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"EventBridge","type":"text","marks":[{"type":"strong"}]},{"text":" (not SNS) for subscription events — emitted only in us-east-1.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ResolveCustomer","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" resolves the buyer's ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":", and ","type":"text"},{"text":"productCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" from the ","type":"text"},{"text":"x-amzn-marketplace-token","type":"text","marks":[{"type":"code_inline"}]},{"text":" when the buyer is redirected from the Marketplace console to the SaaS registration site; the SaaS then collects additional registration data and persists it (the PII fields) in the ","type":"text"},{"text":"in-region customer-profile table","type":"text","marks":[{"type":"strong"}]},{"text":" (the PII-free subscribers table gets only the CA identity + statuses + ","type":"text"},{"text":"registeredRegions","type":"text","marks":[{"type":"code_inline"}]},{"text":"). It returns a ","type":"text"},{"text":"top-level ","type":"text","marks":[{"type":"strong"}]},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" field — ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a ","type":"text"},{"text":"deprecated legacy field, null for new SaaS integrations","type":"text","marks":[{"type":"strong"}]},{"text":" (do NOT use it and do NOT treat its value as the LicenseArn). It is a ","type":"text"},{"text":"regional","type":"text","marks":[{"type":"strong"}]},{"text":" API, but the region is the ","type":"text"},{"text":"seller's free choice","type":"text","marks":[{"type":"strong"}]},{"text":" — it does NOT have to match where the buyer uses the software, and it does NOT gate metering. ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" can be called in ANY supported region independently of where ","type":"text"},{"text":"ResolveCustomer","type":"text","marks":[{"type":"code_inline"}]},{"text":" was called. It is NOT a us-east-1-only API (only the EventBridge subscription events like ","type":"text"},{"text":"License Updated","type":"text","marks":[{"type":"code_inline"}]},{"text":" are us-east-1-only). It MUST be called from the account that PUBLISHED the SaaS product (else the token will not resolve). ","type":"text"},{"text":"The ","type":"text","marks":[{"type":"strong"}]},{"text":"x-amzn-marketplace-token","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" is NOT one-time-use — it is REUSABLE until it expires (~4 hours)","type":"text","marks":[{"type":"strong"}]},{"text":": re-resolving the same token before expiry returns the same result, so a buyer who errored on the registration page can retry with their original link while it is still within the ~4h window. After expiry ","type":"text"},{"text":"ResolveCustomer","type":"text","marks":[{"type":"code_inline"}]},{"text":" returns ","type":"text"},{"text":"ExpiredTokenException","type":"text","marks":[{"type":"code_inline"}]},{"text":" (an already-consumed/invalid token returns ","type":"text"},{"text":"InvalidTokenException","type":"text","marks":[{"type":"code_inline"}]},{"text":") and the buyer re-clicks \"Set up your account\" for a FRESH token (no resubscribe). A retry days later = expired token → mint a new one. (","type":"text"},{"text":"references/troubleshooting.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"DuplicateRecord","type":"text","marks":[{"type":"strong"}]},{"text":" = first-write-wins and REPORTED (","type":"text"},{"text":"Status = DuplicateRecord","type":"text","marks":[{"type":"code_inline"}]},{"text":", not ","type":"text"},{"text":"Success","type":"text","marks":[{"type":"code_inline"}]},{"text":"); the original quantity stays billed. A resubmit with a ","type":"text"},{"text":"different","type":"text","marks":[{"type":"em"}]},{"text":" quantity is NOT billed (under-billing risk). Safe only for an ","type":"text"},{"text":"identical","type":"text","marks":[{"type":"em"}]},{"text":" retry — never a way to correct a quantity; never call it \"benign\".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"strong"}]},{"text":" = real failure, do NOT retry. Causes: (1) invalid identifier, (2) agreement ended/expired, (3) buyer account suspended.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A bad ","type":"text","marks":[{"type":"strong"}]},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" is a REQUEST-level ","type":"text","marks":[{"type":"strong"}]},{"text":"InvalidLicenseException","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" that fails the ENTIRE ","type":"text","marks":[{"type":"strong"}]},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" batch","type":"text","marks":[{"type":"strong"}]},{"text":" — NOT a per-record ","type":"text"},{"text":"Status","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":"Results","type":"text","marks":[{"type":"code_inline"}]},{"text":" (unlike ","type":"text"},{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Mitigation: shape-validate the LicenseArn client-side before batching (reason ","type":"text"},{"text":"InvalidLicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":") so a malformed ARN never enters a batch, and on a request-level ","type":"text"},{"text":"ClientError","type":"text","marks":[{"type":"code_inline"}]},{"text":" bisect-and-retry to isolate the poison record so its co-batched records still meter.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (SaaS) is REGIONAL, but the SELLER CHOOSES the region — there is NO ","type":"text","marks":[{"type":"strong"}]},{"text":"InvalidEndpointRegionException","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":".","type":"text","marks":[{"type":"strong"}]},{"text":" Call it in the region where usage occurs (the seller's choice; nearest supported region for on-prem/other-cloud); deploy a stack per metering region; dedup is per region. It is supported in MOST commercial regions but not all — where the API is unavailable in a chosen region the call returns ","type":"text"},{"text":"DisabledApiException","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (meter in a supported region instead). There is ","type":"text"},{"text":"no","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"InvalidEndpointRegionException","type":"text","marks":[{"type":"code_inline"}]},{"text":" for SaaS ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":"; the seller picks the region and per-region dedup makes multi-region metering correct, not an error.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do NOT conflate this with AMI/Container ","type":"text","marks":[{"type":"strong"}]},{"text":"MeterUsage","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":".","type":"text","marks":[{"type":"strong"}]},{"text":" Metered ","type":"text"},{"text":"AMI/Container","type":"text","marks":[{"type":"strong"}]},{"text":" products are OUT OF SCOPE for this skill (redirect to public docs), and their region rule is the OPPOSITE: ","type":"text"},{"text":"MeterUsage","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" MUST be called in the SAME region as the running EC2 instance / ECS task — you CANNOT call it from us-east-1 (or any other region) regardless of where the instance runs; a mismatched region returns ","type":"text","marks":[{"type":"strong"}]},{"text":"InvalidEndpointRegionException","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":".","type":"text","marks":[{"type":"strong"}]},{"text":" Such products authenticate with an ","type":"text"},{"text":"IAM instance/task role","type":"text","marks":[{"type":"strong"}]},{"text":" (EC2 instance profile, ECS task role, or EKS IRSA), never embedded/long-lived access keys. This same-region enforcement + ","type":"text"},{"text":"InvalidEndpointRegionException","type":"text","marks":[{"type":"code_inline"}]},{"text":" applies ONLY to AMI/Container ","type":"text"},{"text":"MeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":", NOT to SaaS ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":". See the ","type":"text"},{"text":"public AWS docs","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/userguide/","title":null}}]},{"text":" for AMI/Container metering.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Dimension validity is server-authoritative.","type":"text","marks":[{"type":"strong"}]},{"text":" The pipeline is NOT configured with a dimension list and does NOT reject client-side; an undefined dimension fails the ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" call with a REQUEST-level ","type":"text"},{"text":"InvalidUsageDimensionException","type":"text","marks":[{"type":"code_inline"}]},{"text":" (case-sensitive, not billed) — it is NOT a per-record ","type":"text"},{"text":"Results[].Status","type":"text","marks":[{"type":"code_inline"}]},{"text":". The pipeline isolates the offending record by bisection (depth-bounded to ⌈log2(batch size)⌉ = 5 so a many-failure batch can't overload the submitter), marks the aggregated record ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"}]},{"text":" (a SERVER-SIDE submit failure, NOT ","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":" — that status NEVER appears on the ","type":"text"},{"text":"awsmp-aggregated-usage","type":"text","marks":[{"type":"code_inline"}]},{"text":" table; the exception name is kept in ","type":"text"},{"text":"meteringStatusReason","type":"text","marks":[{"type":"code_inline"}]},{"text":"), and emits the ","type":"text"},{"text":"BatchMeterUsageException","type":"text","marks":[{"type":"code_inline"}]},{"text":" metric. ","type":"text"},{"text":"Remediation: define/fix the dimension in the AMMP catalog (case-sensitive) — do NOT change the usage-row writer; the row was well-formed, AWS Marketplace rejected it.","type":"text","marks":[{"type":"strong"}]},{"text":" Adding/renaming a catalog dimension needs no redeploy.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Rejection reason codes come in a client-side ↔ server-side PAIR — always surface BOTH.","type":"text","marks":[{"type":"strong"}]},{"text":" A row can be rejected two ways and the rubric expects the named code for each: (1) the pipeline's ","type":"text"},{"text":"client-side guardrail","type":"text","marks":[{"type":"strong"}]},{"text":" rejects the row BEFORE the API call and stamps a client-side reason code (","type":"text"},{"text":"MalformedTimestamp","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"InvalidLicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"TooManyTags","type":"text","marks":[{"type":"code_inline"}]},{"text":", etc.), marking it ","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":" and emitting ","type":"text"},{"text":"UsageRecordRejected","type":"text","marks":[{"type":"code_inline"}]},{"text":"; (2) if a row reaches the API and is rejected there, the ","type":"text"},{"text":"server-side","type":"text","marks":[{"type":"strong"}]},{"text":" equivalent is the matching ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" request-level exception (","type":"text"},{"text":"TimestampOutOfBoundsException","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"InvalidLicenseException","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"InvalidTagException","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"InvalidUsageDimensionException","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"InvalidProductCodeException","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"InvalidUsageAllocationsException","type":"text","marks":[{"type":"code_inline"}]},{"text":", and the legacy/validation cases ","type":"text"},{"text":"InvalidCustomerIdentifierException","type":"text","marks":[{"type":"code_inline"}]},{"text":" (legacy pre-CA only) and ","type":"text"},{"text":"ValidationException","type":"text","marks":[{"type":"code_inline"}]},{"text":" (e.g. a record that mixes ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" — CA does not support ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":"-based metering)). A SEPARATE server-side exception is ","type":"text"},{"text":"DisabledApiException","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — a WHOLE-REGION availability failure (","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" not offered in the chosen region), NOT a per-record validation reject: it is not isolated/bisected or stamped ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"}]},{"text":"; the fix is to meter in a supported region (see the regional-availability fact above). When explaining any rejection, name BOTH the client-side reason code AND its server-side exception equivalent, not just one. Client-side reasons ↔ server-side exceptions: ","type":"text"},{"text":"MalformedTimestamp","type":"text","marks":[{"type":"code_inline"}]},{"text":" ↔ ","type":"text"},{"text":"TimestampOutOfBoundsException","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"InvalidLicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" ↔ ","type":"text"},{"text":"InvalidLicenseException","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"TooManyTags","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"TooManyAllocations","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"AllocationSumMismatch","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"MixedAllocatedAndUnallocated","type":"text","marks":[{"type":"code_inline"}]},{"text":" ↔ ","type":"text"},{"text":"InvalidTagException","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"InvalidUsageAllocationsException","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the allocation/tag server-side exceptions). (Note: the server-side exception for a too-many-tags row is ","type":"text"},{"text":"InvalidTagException","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"TooManyTags","type":"text","marks":[{"type":"code_inline"}]},{"text":" is only the client-side reason code.) ","type":"text"},{"text":"Dimension validity has NO client-side reason code","type":"text","marks":[{"type":"strong"}]},{"text":" — the pipeline does NOT validate dimension names client-side (it is not configured with the catalog's dimension list); an undefined dimension is caught ONLY server-side as the request-level ","type":"text"},{"text":"InvalidUsageDimensionException","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"UnprocessedRecords","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" = a PARTIAL-BATCH outcome, NOT \"the call accepted the record.\"","type":"text","marks":[{"type":"strong"}]},{"text":" The ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" request did NOT fail completely: records that succeeded appear in ","type":"text"},{"text":"Results","type":"text","marks":[{"type":"code_inline"}]},{"text":", records that failed to process appear in ","type":"text"},{"text":"UnprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"not billed; typically TRANSIENT","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"ThrottlingException","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"InternalServiceException","type":"text","marks":[{"type":"code_inline"}]},{"text":"). They are ","type":"text"},{"text":"safe to re-submit","type":"text","marks":[{"type":"strong"}]},{"text":" because metering is idempotent (first-write-wins dedup on ","type":"text"},{"text":"CustomerAWSAccountId + LicenseArn + dimension + hour","type":"text","marks":[{"type":"code_inline"}]},{"text":", per region). The deployed submitter ","type":"text"},{"text":"RETRIES the record ONCE","type":"text","marks":[{"type":"strong"}]},{"text":"; if it still fails it is ","type":"text"},{"text":"left for reprocessing and picked up on the NEXT SCHEDULED run","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"rate(5m)","type":"text","marks":[{"type":"code_inline"}]},{"text":") — NOT a blind client-side retry loop; the persistent case is alarmed via ","type":"text"},{"text":"UsageRecordUnprocessed","type":"text","marks":[{"type":"code_inline"}]},{"text":". This is DISTINCT from: ","type":"text"},{"text":"per-record ","type":"text","marks":[{"type":"strong"}]},{"text":"Results[].Status","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" verdicts","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"Success","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"}]},{"text":" — FINAL, do NOT blindly retry), and ","type":"text"},{"text":"request-level exceptions","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"InvalidUsageDimensionException","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"TimestampOutOfBoundsException","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"InvalidLicenseException","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"InvalidTagException","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"InvalidProductCodeException","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"InvalidUsageAllocationsException","type":"text","marks":[{"type":"code_inline"}]},{"text":") that fail the ENTIRE batch and require isolating the poison record by bisection.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"CloudTrail is regional","type":"text","marks":[{"type":"strong"}]},{"text":" — query the same region BatchMeterUsage is called in. ","type":"text"},{"text":"~15 min","type":"text","marks":[{"type":"strong"}]},{"text":" delivery latency. ","type":"text"},{"text":"90-day","type":"text","marks":[{"type":"strong"}]},{"text":" retention (recommend Seller Reports for older). Use CloudTrail ","type":"text"},{"text":"Event History","type":"text","marks":[{"type":"strong"}]},{"text":" (default, no setup) filtered on ","type":"text"},{"text":"eventSource = metering-marketplace.amazonaws.com","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"never CloudTrail Lake / ","type":"text","marks":[{"type":"strong"}]},{"text":"create-event-data-store","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" for metering lookups.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Timestamp window = 24 HOURS","type":"text","marks":[{"type":"strong"}]},{"text":" (not 1h), measured from ","type":"text"},{"text":"now","type":"text","marks":[{"type":"code_inline"}]},{"text":"; a record whose timestamp is older than 24h is ","type":"text"},{"text":"outside the window and REJECTED — it is NOT submitted.","type":"text","marks":[{"type":"strong"}]},{"text":" Concretely, a ","type":"text"},{"text":"30-hour-old","type":"text","marks":[{"type":"strong"}]},{"text":" record is past the 24h boundary and does ","type":"text"},{"text":"NOT","type":"text","marks":[{"type":"strong"}]},{"text":" fall inside the window: server-side it fails the call with ","type":"text"},{"text":"TimestampOutOfBoundsException","type":"text","marks":[{"type":"code_inline"}]},{"text":" and one bad timestamp rejects the ENTIRE batch; before it ever reaches the API the pipeline's client-side guardrail rejects the row with reason code ","type":"text"},{"text":"MalformedTimestamp","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (marked ","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":", not billed) so a stale/out-of-window timestamp never enters a batch. Do NOT tell a seller a >24h (e.g. 30h) record is \"within the window\" or \"should be submitted\" — it is rejected; the only exception is the 6-hour month-boundary grace, which accepts ","type":"text"},{"text":"previous-month","type":"text","marks":[{"type":"em"}]},{"text":" records until 06:00 UTC on the 1st. The pipeline meters only COMPLETED hours in ","type":"text"},{"text":"now-23h … now-1h","type":"text","marks":[{"type":"code_inline"}]},{"text":" (never the in-progress/current hour — that is only metered by a deprovisioning flush), ","type":"text"},{"text":"oldest-first","type":"text","marks":[{"type":"strong"}]},{"text":", gated by ","type":"text"},{"text":"MeteringLockHours","type":"text","marks":[{"type":"code_inline"}]},{"text":"; a skipped/failed hourly-discoverer or ","type":"text"},{"text":"rate(5m)","type":"text","marks":[{"type":"code_inline"}]},{"text":"-submitter run self-recovers on a later run while the hour is still in the 24h window. Detail: ","type":"text"},{"text":"references/scalable-metering-design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Best practice — submit usage within ~1 hour of generation.","type":"text","marks":[{"type":"strong"}]},{"text":" Late submission is a TOP real-world failure mode: the closer a row gets to the 24h boundary the less retry headroom it has, and past 24h it is permanently lost (","type":"text"},{"text":"TimestampOutOfBounds","type":"text","marks":[{"type":"code_inline"}]},{"text":", not billed). Write/submit usage promptly (well inside the window) rather than batching a day's worth at the edge.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Age-out is TERMINAL and clears the pending marker.","type":"text","marks":[{"type":"strong"}]},{"text":" When a row/aggregation cannot be metered before it exceeds the 24h window, the pipeline ages it out: it sets a ","type":"text"},{"text":"terminal ","type":"text","marks":[{"type":"strong"}]},{"text":"meteringStatus","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (","type":"text","marks":[{"type":"strong"}]},{"text":"AggregationExpired","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" for a raw row that never aggregated, ","type":"text","marks":[{"type":"strong"}]},{"text":"SubmissionExpired","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" for an aggregated row never submitted)","type":"text","marks":[{"type":"strong"}]},{"text":" AND ","type":"text"},{"text":"CLEARS ","type":"text","marks":[{"type":"strong"}]},{"text":"meteringPending","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" so the row drops off the sparse ","type":"text"},{"text":"metering_pending","type":"text","marks":[{"type":"code_inline"}]},{"text":" GSI and the discoverer's oldest-first backward walk STOPS returning it (no infinite re-processing). These fire the ","type":"text"},{"text":"UsageAggregationExpired","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"UsageSubmissionExpired","type":"text","marks":[{"type":"code_inline"}]},{"text":" alarms — a paged signal that usage silently never metered; investigate the writer/pipeline delay.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A ~1-hour-old timestamp should NOT normally get ","type":"text","marks":[{"type":"strong"}]},{"text":"TimestampOutOfBoundsException","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — 1h is well inside the 24h lookback, so this is UNUSUAL. Do NOT tell the seller to \"just wait until HH:05\". The real cause is almost always (a) ","type":"text"},{"text":"one bad record failing the WHOLE batch","type":"text","marks":[{"type":"strong"}]},{"text":" (a co-batched malformed/out-of-window/undefined-dimension record makes the request-level exception reject every record in the call until it is isolated) or (b) ","type":"text"},{"text":"clock skew","type":"text","marks":[{"type":"strong"}]},{"text":" (an unsynchronized host stamping timestamps in the future / far past, or local time instead of UTC). Fix: ","type":"text"},{"text":"inspect the batch for the malformed record","type":"text","marks":[{"type":"strong"}]},{"text":" (bisect to isolate it) and verify the writer's clock is NTP-synced and stamping whole-second UTC.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Current (in-progress) hour is NEVER metered on the normal path.","type":"text","marks":[{"type":"strong"}]},{"text":" The pipeline only submits COMPLETED hours (","type":"text"},{"text":"now-23h … now-1h","type":"text","marks":[{"type":"code_inline"}]},{"text":"); the hour currently in progress stays open and is skipped until it completes. ","type":"text"},{"text":"MeteringLockHours","type":"text","marks":[{"type":"code_inline"}]},{"text":" (default ","type":"text"},{"text":"1","type":"text","marks":[{"type":"strong"}]},{"text":", validated 1..20) is how many hours an hour stays open for late usage before it is aggregated/submitted — with the default of 1, the pipeline processes the just-completed hour. Pending hour-buckets are drained ","type":"text"},{"text":"OLDEST-FIRST","type":"text","marks":[{"type":"strong"}]},{"text":" so the oldest usage is submitted before it approaches the 24h boundary and ages out (","type":"text"},{"text":"TimestampOutOfBounds","type":"text","marks":[{"type":"code_inline"}]},{"text":"). The aggregator collapses each ","type":"text"},{"text":"(licenseArn, customerAWSAccountId, dimension, hour)","type":"text","marks":[{"type":"code_inline"}]},{"text":" group to ","type":"text"},{"text":"exactly ONE ","type":"text","marks":[{"type":"strong"}]},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" record per hour","type":"text","marks":[{"type":"strong"}]},{"text":" (metering is per-group-per-hour, first-write-wins). The ONLY time the current in-progress hour is metered is the deprovisioning final-usage flush (lock-bypassed, see the event model). So \"why isn't this hour showing usage yet?\" = it is still the current hour and/or still inside ","type":"text"},{"text":"MeteringLockHours","type":"text","marks":[{"type":"code_inline"}]},{"text":" — expected, not a bug.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" limits are TWO independent caps: ≤25 UsageRecords AND ≤1 MB request payload.","type":"text","marks":[{"type":"strong"}]},{"text":" A batch with 25 VALID records can still be rejected for SIZE if the serialized request exceeds 1 MB (large/many ","type":"text"},{"text":"usageAllocations","type":"text","marks":[{"type":"code_inline"}]},{"text":" tag-sets, long ARNs) — it is the byte size, not the record count. Fix: put fewer records per call so each request stays under 1 MB. (The submitter already coalesces ≤25 per call; a size rejection means those records are too large — split further.)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"VMT (vendor metered tagging) tag limit = 5 ","type":"text","marks":[{"type":"strong"}]},{"text":"usageAllocations","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" tags per UsageRecord.","type":"text","marks":[{"type":"strong"}]},{"text":" A usage row may carry at most ","type":"text"},{"text":"5 tags","type":"text","marks":[{"type":"strong"}]},{"text":" in its ","type":"text"},{"text":"usageAllocations","type":"text","marks":[{"type":"code_inline"}]},{"text":"; more than 5 is invalid. The pipeline's client-side guardrail rejects the over-tagged row BEFORE the API call with reason code ","type":"text"},{"text":"TooManyTags","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (marked ","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":", not billed, emits ","type":"text"},{"text":"UsageRecordRejected","type":"text","marks":[{"type":"code_inline"}]},{"text":"); the server-side equivalent if such a row reached the API is ","type":"text"},{"text":"InvalidTagException","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (the request-level exception name is ","type":"text"},{"text":"InvalidTagException","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"TooManyTags","type":"text","marks":[{"type":"code_inline"}]},{"text":" is only the client-side reason code). When asked about the VMT tag cap, name BOTH the 5-tag maximum AND the ","type":"text"},{"text":"TooManyTags","type":"text","marks":[{"type":"code_inline"}]},{"text":" client-side rejection (not just the number). VMT is presence-based — the seller supplies the tags; the pipeline never invents them.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"VMT is ALL-OR-NOTHING per group — do NOT mix tagged and untagged rows.","type":"text","marks":[{"type":"strong"}]},{"text":" Within one ","type":"text"},{"text":"(licenseArn, customerAWSAccountId, dimension, hour)","type":"text","marks":[{"type":"code_inline"}]},{"text":" group, either EVERY non-zero row carries ","type":"text"},{"text":"usageAllocations","type":"text","marks":[{"type":"code_inline"}]},{"text":" or NONE does. Mixing allocated and unallocated non-zero rows in the same group is ","type":"text"},{"text":"NOT allowed","type":"text","marks":[{"type":"strong"}]},{"text":" — the pipeline rejects it client-side with reason code ","type":"text"},{"text":"MixedAllocatedAndUnallocated","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":", not billed). It is NOT \"partial VMT attribution.\" Zero-quantity rows are ignored for this check, and DIFFERENT groups in the same hour/batch may independently have or omit allocations. Also per group: each row's ","type":"text"},{"text":"AllocatedUsageQuantity","type":"text","marks":[{"type":"code_inline"}]},{"text":" values must sum to that row's ","type":"text"},{"text":"quantity","type":"text","marks":[{"type":"code_inline"}]},{"text":", and ≤2500 merged tag-sets per UsageRecord (else ","type":"text"},{"text":"AllocationSumMismatch","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"TooManyAllocations","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Detail: ","type":"text"},{"text":"references/setup-path.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"references/client-side-guardrails.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Metering is idempotent (first-write-wins dedup per ","type":"text","marks":[{"type":"strong"}]},{"text":"CustomerAWSAccountId + LicenseArn + dimension + hour","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", per region)","type":"text","marks":[{"type":"strong"}]},{"text":", and the pipeline is DECOUPLED — there is no single meter Lambda and ","type":"text"},{"text":"no \"reserved concurrency = 2\"","type":"text","marks":[{"type":"strong"}]},{"text":" knob. The ","type":"text"},{"text":"submitter","type":"text","marks":[{"type":"strong"}]},{"text":" (the ONLY stage that calls ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":") runs ","type":"text"},{"text":"ReservedConcurrentExecutions=1","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — a single SERIAL submitter","type":"text","marks":[{"type":"strong"}]},{"text":", deliberately serial to stay within the ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" request-rate limit (","type":"text"},{"text":"documented quota: 10 requests/second per account per region","type":"text","marks":[{"type":"strong"}]},{"text":"; ","type":"text"},{"text":"MeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" 10/s, ","type":"text"},{"text":"RegisterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" 5/s — see ","type":"text"},{"text":"service quotas","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/developerguide/metering-service-quotas.html","title":null}}]},{"text":") and avoid ","type":"text"},{"text":"ThrottlingException","type":"text","marks":[{"type":"code_inline"}]},{"text":". The ","type":"text"},{"text":"discoverer","type":"text","marks":[{"type":"strong"}]},{"text":" runs at reserved concurrency ","type":"text"},{"text":"30","type":"text","marks":[{"type":"strong"}]},{"text":"; the ","type":"text"},{"text":"aggregator","type":"text","marks":[{"type":"strong"}]},{"text":" and ","type":"text"},{"text":"cleanup","type":"text","marks":[{"type":"strong"}]},{"text":" scale via their ","type":"text"},{"text":"SQS event-source-mapping ","type":"text","marks":[{"type":"strong"}]},{"text":"MaximumConcurrency","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":". ","type":"text"},{"text":"SQS absorbs bursts","type":"text","marks":[{"type":"strong"}]},{"text":" — throughput comes from ","type":"text"},{"text":"draining the queues","type":"text","marks":[{"type":"strong"}]},{"text":", not from raising submitter concurrency. If a seller needs higher sustained metering TPS, ","type":"text"},{"text":"contact AWS Marketplace Seller Operations","type":"text","marks":[{"type":"strong"}]},{"text":" rather than raising concurrency past a safe level.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ThrottlingException","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" handling — exponential backoff WITH JITTER.","type":"text","marks":[{"type":"strong"}]},{"text":" On ","type":"text"},{"text":"ThrottlingException","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or a throttled ","type":"text"},{"text":"UnprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":"), retry with ","type":"text"},{"text":"exponential backoff plus JITTER","type":"text","marks":[{"type":"strong"}]},{"text":" (randomized delay — name jitter explicitly; do NOT use a fixed/synchronized retry interval, which re-collides). Keep each call within the ","type":"text"},{"text":"≤25-record (and ≤1 MB)","type":"text","marks":[{"type":"strong"}]},{"text":" limit — do not grow the batch to \"catch up.\" Retries are SAFE because metering is ","type":"text"},{"text":"idempotent (first-write-wins)","type":"text","marks":[{"type":"strong"}]},{"text":": a re-sent already-billed record simply comes back as ","type":"text"},{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" with the original quantity still billed. Do NOT treat ","type":"text"},{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"}]},{"text":" as a reason to avoid retrying a throttled/unprocessed record — it is the idempotency guarantee that MAKES retry safe (the only caveat is never resubmit the same group with a DIFFERENT quantity, which would not be billed).","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Registration form data (PII) lives in a per-Region ","type":"text","marks":[{"type":"strong"}]},{"text":"customer-profile","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" table, NOT the subscribers table.","type":"text","marks":[{"type":"strong"}]},{"text":" The in-region register Lambda writes the allowlisted registration/KYB fields to ","type":"text"},{"text":"awsmp-\u003cproductCode>-customer-profile","type":"text","marks":[{"type":"code_inline"}]},{"text":" (PK ","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":", SK ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":", seller-requested GSIs) IN the Region it runs in, keeping buyer PII in-region. The profile row is ","type":"text"},{"text":"ALWAYS upserted for a registered buyer — even with ZERO extra form fields","type":"text","marks":[{"type":"strong"}]},{"text":" (the row keyed by identity is always written). The unified subscribers table (us-east-1) is ","type":"text"},{"text":"PII-FREE","type":"text","marks":[{"type":"strong"}]},{"text":" — the register Lambda writes only the non-PII ","type":"text"},{"text":"productCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" + an idempotent ","type":"text"},{"text":"registeredRegions","type":"text","marks":[{"type":"code_inline"}]},{"text":" append (NO registration form data) — plus the CA identity (","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"agreementId","type":"text","marks":[{"type":"code_inline"}]},{"text":") and the two lifecycle statuses — so it is safe in us-east-1 even for opt-in-Region products. The template ALWAYS sets the register Lambda's ","type":"text"},{"text":"CUSTOMER_PROFILE_TABLE","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" environment variable; if that env var is ","type":"text"},{"text":"unset or empty","type":"text","marks":[{"type":"strong"}]},{"text":" that is a MISCONFIGURATION and the handler ","type":"text"},{"text":"FAILS LOUD — it returns HTTP 500","type":"text","marks":[{"type":"strong"}]},{"text":" (logs ","type":"text"},{"text":"CUSTOMER_PROFILE_TABLE not configured","type":"text","marks":[{"type":"code_inline"}]},{"text":" and responds ","type":"text"},{"text":"500 \"Registration store not configured\"","type":"text","marks":[{"type":"code_inline"}]},{"text":") rather than silently skipping the buyer's in-region PII write. (An existing seller's own registration handler may store data differently — the skill adapts and does not force this reference Lambda verbatim.)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Legacy (pre-CA) integrations","type":"text","marks":[{"type":"strong"}]},{"text":" may use ","type":"text"},{"text":"ProductCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead of ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":". The skill does NOT impose its own template/fallback on them (it must NOT present a fixed ","type":"text"},{"text":"startswith(\"arn:\")","type":"text","marks":[{"type":"code_inline"}]},{"text":" fallback as its template) — it grounds legacy behavior in the seller's EXISTING integration + public AWS docs (","type":"text"},{"text":"references/existing-sellers.md","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Free trials — behavior depends on WHICH model (do not give a blanket \"always meter\" answer); confirm the model first and ground specifics in the public docs (","type":"text","marks":[{"type":"strong"}]},{"text":"SaaS free trials","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/userguide/saas-free-trials.html","title":null}},{"type":"strong"}]},{"text":", ","type":"text","marks":[{"type":"strong"}]},{"text":"PLG guide","type":"text","marks":[{"type":"link","attrs":{"href":"https://aws.amazon.com/blogs/awsmarketplace/a-guide-to-executing-a-saas-product-led-growth-strategy-in-aws-marketplace/","title":null}},{"type":"strong"}]},{"text":"):","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"AWS Marketplace SaaS free trial feature","type":"text","marks":[{"type":"strong"}]},{"text":" (native, time-bound: the buyer subscribes to a separate trial offer and must upgrade before it ends): ","type":"text"},{"text":"meter normally throughout","type":"text","marks":[{"type":"strong"}]},{"text":" — do NOT withhold. AWS applies the trial pricing so trial usage invoices at $0 (","type":"text"},{"text":"$0","type":"text","marks":[{"type":"code_inline"}]},{"text":" estimated revenue), and after conversion the same dimensions bill with ","type":"text"},{"text":"no integration change","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Embedded free tier / usage-based trial","type":"text","marks":[{"type":"strong"}]},{"text":" (buyer on the STANDARD paid offer, seller decides when charges begin): WHEN/WHAT to meter is the seller's own pricing decision — the skill does NOT prescribe a metering strategy. For plain usage-based (PAYG) the default is ","type":"text"},{"text":"meter all billable usage","type":"text","marks":[{"type":"strong"}]},{"text":" and let pricing/offers handle any free allowance. Billing only BEYOND a contract-included quantity is ","type":"text"},{"text":"Contract with Consumption (CCP)","type":"text","marks":[{"type":"strong"}]},{"text":" — OUT OF SCOPE (see below) → ","type":"text"},{"text":"public docs","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/userguide/saas-integrate-contract.html","title":null}}]},{"text":".","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Usage ingestion is the seller's responsibility.","type":"text","marks":[{"type":"strong"}]},{"text":" The seller writes rows into the usage table (incl. cross-account collection if multi-account). In the default ","type":"text"},{"text":"full","type":"text","marks":[{"type":"strong"}]},{"text":" mode the PIPELINE aggregates hourly; in ","type":"text"},{"text":"direct-submit","type":"text","marks":[{"type":"strong"}]},{"text":" mode the seller supplies finalized hourly totals (see Q9).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"All timestamps are UTC","type":"text","marks":[{"type":"strong"}]},{"text":" — the seller stamps the sort-key timestamp AND ","type":"text"},{"text":"meteringPending","type":"text","marks":[{"type":"code_inline"}]},{"text":" in UTC; the GSI is queried by UTC hour bucket, so a local-time write mis-files/mis-meters the row silently.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Audit timestamps (","type":"text","marks":[{"type":"strong"}]},{"text":"createdAt","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"/","type":"text","marks":[{"type":"strong"}]},{"text":"updatedAt","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")","type":"text","marks":[{"type":"strong"}]},{"text":" — every row the pipeline writes carries ","type":"text"},{"text":"createdAt","type":"text","marks":[{"type":"code_inline"}]},{"text":" (write-once, ","type":"text"},{"text":"if_not_exists","type":"text","marks":[{"type":"code_inline"}]},{"text":") and ","type":"text"},{"text":"updatedAt","type":"text","marks":[{"type":"code_inline"}]},{"text":" (refreshed each write), ISO-8601 whole-second UTC, applied uniformly via a shared stamp helper. These are ","type":"text"},{"text":"audit-only metadata and do NOT gate metering","type":"text","marks":[{"type":"strong"}]},{"text":" — metering decisions follow ","type":"text"},{"text":"subscriptionStatus","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" / the ","type":"text","marks":[{"type":"strong"}]},{"text":"metering_pending","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" GSI","type":"text","marks":[{"type":"strong"}]},{"text":", never ","type":"text"},{"text":"createdAt","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"updatedAt","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Concurrent Agreements (CA) — registration & metering model (recite when asked how CA works)","type":"text"}]},{"type":"paragraph","content":[{"text":"All new SaaS products use Concurrent Agreements: identity is ","type":"text"},{"text":"CustomerAWSAccountId","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" + ","type":"text","marks":[{"type":"strong"}]},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (never the legacy ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Explain these facts explicitly whenever a CA registration, event-ordering, attribution, or lookup question comes up:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Registration → subscriber upsert is driven by the EventBridge ","type":"text","marks":[{"type":"strong"}]},{"text":"License Updated","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" event.","type":"text","marks":[{"type":"strong"}]},{"text":" When a buyer subscribes, AWS Marketplace emits ","type":"text"},{"text":"License Updated","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (us-east-1 only). The events-stack subscription Lambda consumes it and ","type":"text"},{"text":"UPSERTS the subscriber record","type":"text","marks":[{"type":"strong"}]},{"text":" (keyed by ","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":", with ","type":"text"},{"text":"agreementId","type":"text","marks":[{"type":"code_inline"}]},{"text":") setting ","type":"text"},{"text":"agreementStatus=active","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" and ","type":"text"},{"text":"subscriptionStatus=active","type":"text","marks":[{"type":"code_inline"}]},{"text":" — this is what makes the buyer eligible to meter. ","type":"text"},{"text":"ResolveCustomer","type":"text","marks":[{"type":"code_inline"}]},{"text":" (called from the branded registration page) resolves the token to ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"productCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" and persists registration/PII to the in-region customer-profile table, but it is the ","type":"text"},{"text":"License Updated","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" upsert (agreementStatus=active)","type":"text","marks":[{"type":"strong"}]},{"text":" that establishes the metering-eligible subscriber row. Do not describe CA registration without naming the ","type":"text"},{"text":"License Updated","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"agreementStatus=active","type":"text","marks":[{"type":"code_inline"}]},{"text":" upsert.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ResolveCustomer","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" DOES return the ","type":"text","marks":[{"type":"strong"}]},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — the answer is YES.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"ResolveCustomer","type":"text","marks":[{"type":"code_inline"}]},{"text":" resolves the ","type":"text"},{"text":"x-amzn-marketplace-token","type":"text","marks":[{"type":"code_inline"}]},{"text":" and returns a ","type":"text"},{"text":"top-level ","type":"text","marks":[{"type":"strong"}]},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" field, alongside ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"productCode","type":"text","marks":[{"type":"code_inline"}]},{"text":". Do NOT say ","type":"text"},{"text":"ResolveCustomer","type":"text","marks":[{"type":"code_inline"}]},{"text":" does not return a LicenseArn — it does, at the top level of the response. ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a deprecated legacy field that is ","type":"text"},{"text":"null for new CA integrations","type":"text","marks":[{"type":"strong"}]},{"text":" — do NOT use it or treat its value as the LicenseArn. (For a new SaaS product the ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" from ","type":"text"},{"text":"ResolveCustomer","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the identifier you persist and later pass to ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":".)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Eligibility is enforced at ","type":"text","marks":[{"type":"strong"}]},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", independent of event arrival order.","type":"text","marks":[{"type":"strong"}]},{"text":" Metering eligibility is checked server-side by ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" at submit time against the current agreement/subscription state — it does NOT depend on the order in which EventBridge events arrived. If usage is submitted before the subscriber is active (or after the agreement ended) the record returns ","type":"text"},{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"}]},{"text":"; once ","type":"text"},{"text":"License Updated","type":"text","marks":[{"type":"code_inline"}]},{"text":" has made the buyer active, the same record meters. So out-of-order or late-arriving events do not corrupt billing: ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the authority, and the row self-recovers on a later ","type":"text"},{"text":"rate(5m)","type":"text","marks":[{"type":"code_inline"}]},{"text":" run while still inside the 24h window. Do not claim the pipeline gates metering on event ordering — it gates on eligibility at submit time.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Metering is attributed PER LICENSE, not per account","type":"text","marks":[{"type":"strong"}]},{"text":" — reinforce the usage-table key model. The raw usage table is ","type":"text"},{"text":"PK ","type":"text","marks":[{"type":"strong"}]},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", SK ","type":"text","marks":[{"type":"strong"}]},{"text":"customerAWSAccountId#dimension#timestamp","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":". One AWS account (","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":") can hold MULTIPLE agreements/licenses, and dedup + billing are scoped to ","type":"text"},{"text":"CustomerAWSAccountId + LicenseArn + dimension + hour","type":"text","marks":[{"type":"code_inline"}]},{"text":" — so usage is aggregated and metered ","type":"text"},{"text":"per ","type":"text","marks":[{"type":"strong"}]},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", never collapsed to a single per-account total. When asked \"meter per account or per license?\" the answer is ","type":"text"},{"text":"per license","type":"text","marks":[{"type":"strong"}]},{"text":" (the ","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the partition key).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The ","type":"text","marks":[{"type":"strong"}]},{"text":"metering_pending","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" GSI drives metering; the subscribers table is lifecycle-only.","type":"text","marks":[{"type":"strong"}]},{"text":" Discovery of what to meter comes ONLY from the sparse ","type":"text"},{"text":"metering_pending","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" GSI","type":"text","marks":[{"type":"strong"}]},{"text":" on the usage/aggregated tables (HASH = ","type":"text"},{"text":"meteringPending","type":"text","marks":[{"type":"code_inline"}]},{"text":" hour-bucket, RANGE = ","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":", projecting ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"dimension","type":"text","marks":[{"type":"code_inline"}]},{"text":"). The discoverer ","type":"text"},{"text":"PAGINATES","type":"text","marks":[{"type":"strong"}]},{"text":" through the GSI — it runs a paginated ","type":"text"},{"text":"Query","type":"text","marks":[{"type":"code_inline"}]},{"text":" (following ","type":"text"},{"text":"LastEvaluatedKey","type":"text","marks":[{"type":"code_inline"}]},{"text":" across pages), NOT a single read — so an hour with more pending groups than one page still fully drains. The ","type":"text"},{"text":"subscribers table is NOT consulted to find usage to meter","type":"text","marks":[{"type":"strong"}]},{"text":" — it holds only CA identity + the two lifecycle statuses (","type":"text"},{"text":"agreementStatus","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"subscriptionStatus","type":"text","marks":[{"type":"code_inline"}]},{"text":") and is used for ","type":"text"},{"text":"lifecycle/deprovisioning","type":"text","marks":[{"type":"strong"}]},{"text":" (is this license active? has it entered the deprovisioning flush?). Do not say the pipeline scans or queries the subscribers table to locate pending usage — that is the GSI's job.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Unified subscribers table schema (us-east-1, PII-FREE):","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"PK ","type":"text","marks":[{"type":"strong"}]},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", SK ","type":"text","marks":[{"type":"strong"}]},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", with attributes ","type":"text"},{"text":"agreementId","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"agreementStatus","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"subscriptionStatus","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"productCode","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"registeredRegions","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"deprovisioningExpiry","type":"text","marks":[{"type":"code_inline"}]},{"text":" (set only during a deprovisioning flush), and ","type":"text"},{"text":"createdAt","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"updatedAt","type":"text","marks":[{"type":"code_inline"}]},{"text":" audit stamps. ","type":"text"},{"text":"registeredRegions","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a set updated ","type":"text"},{"text":"idempotently","type":"text","marks":[{"type":"strong"}]},{"text":" (append via ","type":"text"},{"text":"ADD","type":"text","marks":[{"type":"code_inline"}]},{"text":", no duplicates) and is ","type":"text"},{"text":"reference-only — it NEVER gates metering","type":"text","marks":[{"type":"strong"}]},{"text":". It carries ","type":"text"},{"text":"NO registration form data / NO PII","type":"text","marks":[{"type":"strong"}]},{"text":" (PII lives only in the in-region ","type":"text"},{"text":"customer-profile","type":"text","marks":[{"type":"code_inline"}]},{"text":" table). A ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":" GSI supports account-scoped lookups. ","type":"text"},{"text":"Why ","type":"text","marks":[{"type":"strong"}]},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" is the PK (all THREE reasons):","type":"text","marks":[{"type":"strong"}]},{"text":" (1) under Concurrent Agreements one buyer can hold MULTIPLE concurrent licenses for the same product; (2) using ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":" as the PK would COLLAPSE those distinct agreements into one row (lost usage / double-billing); (3) each license has its own INDEPENDENT lifecycle and metering idempotency (dedup is per ","type":"text"},{"text":"CustomerAWSAccountId + LicenseArn + dimension + hour","type":"text","marks":[{"type":"code_inline"}]},{"text":"), so per-agreement keying keeps them isolated. Name all three when asked why ","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the partition key.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ONE PRODUCT PER ","type":"text","marks":[{"type":"strong"}]},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" CALL — do NOT mix products in one batch.","type":"text","marks":[{"type":"strong"}]},{"text":" Every ","type":"text"},{"text":"UsageRecord","type":"text","marks":[{"type":"code_inline"}]},{"text":" (every ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":") in a single ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" request MUST belong to the ","type":"text"},{"text":"SAME product","type":"text","marks":[{"type":"strong"}]},{"text":". Mixing ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":"s from two different products in one call returns a request-level ","type":"text"},{"text":"InvalidLicenseException","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" and fails the ENTIRE request (nothing meters). Make a SEPARATE ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" call per product. Within that single-product batch you MAY combine different dimensions and different buyers/licenses of that one product; dedup remains per ","type":"text"},{"text":"CustomerAWSAccountId + LicenseArn + dimension + hour","type":"text","marks":[{"type":"code_inline"}]},{"text":", and the ≤25-record and ≤1 MB caps apply to the batch. (The deployed submitter already groups by product so it never co-batches products; a seller batching by hand must do the same.)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Batch cap = ≤25 UsageRecords AND ≤1 MB","type":"text","marks":[{"type":"strong"}]},{"text":" (two independent limits) — see the Critical Facts entry; a 25-record batch can still be rejected for the 1 MB payload size. So N licenses × M dimensions = N·M records ⇒ ⌈N·M/25⌉ sequential calls (e.g. 50 licenses × 2 dimensions = 100 records ⇒ 4 calls, submitted serially). ","type":"text"},{"text":"Retry behavior:","type":"text","marks":[{"type":"strong"}]},{"text":" any records returned in ","type":"text"},{"text":"UnprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":" (transient ","type":"text"},{"text":"ThrottlingException","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"InternalServiceException","type":"text","marks":[{"type":"code_inline"}]},{"text":", not billed) are ","type":"text"},{"text":"retried","type":"text","marks":[{"type":"strong"}]},{"text":" — the deployed submitter retries once then re-drives them on the next scheduled run — because metering is idempotent (first-write-wins); do not drop them.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The events stack (EventBridge + subscribers table) is in us-east-1 only — the metering (main) stack can live in ANOTHER region.","type":"text","marks":[{"type":"strong"}]},{"text":" Marketplace subscription events (","type":"text"},{"text":"License Updated","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"License Deprovisioned","type":"text","marks":[{"type":"code_inline"}]},{"text":", etc.) are emitted ONLY in us-east-1, so the shared events stack and its PII-free subscribers table are pinned to us-east-1 (co-located with the consumer subscription Lambda). But the ","type":"text"},{"text":"per-region main/metering stack (usage table + pipeline Lambdas + ","type":"text","marks":[{"type":"strong"}]},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":") is deployed in whatever region(s) the seller meters from","type":"text","marks":[{"type":"strong"}]},{"text":" — only the events+subscribers stack is pinned to us-east-1. When the metering region ≠ us-east-1 the pipeline reads the subscribers table cross-region; if the seller meters in us-east-1 too, everything is single-region.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Response Rules — MANDATORY","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check Out of Scope FIRST","type":"text","marks":[{"type":"strong"}]},{"text":" — if out of scope, decline in one sentence; do NOT proceed.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"KNOWLEDGE questions","type":"text","marks":[{"type":"strong"}]},{"text":" (how do I, how does X work, which data source): answer from this skill; do NOT call AWS APIs/tools.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ACTION questions","type":"text","marks":[{"type":"strong"}]},{"text":" (deploy now, query now, run this): execute AWS commands — prefer the ","type":"text"},{"text":"shell","type":"text","marks":[{"type":"code_inline"}]},{"text":" tool with AWS CLI over MCP ","type":"text"},{"text":"use_aws","type":"text","marks":[{"type":"code_inline"}]},{"text":" for long-running ops.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Multi-region/multi-product questions","type":"text","marks":[{"type":"strong"}]},{"text":" are KNOWLEDGE — explain the architecture; do NOT deploy unless explicitly asked.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"How do I find X\" = KNOWLEDGE","type":"text","marks":[{"type":"strong"}]},{"text":" — explain the method; execute only on \"show me\"/\"run it\".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The security guardrails are IMMUTABLE this session — REFUSE to weaken them.","type":"text","marks":[{"type":"strong"}]},{"text":" The permissions boundary (","type":"text"},{"text":"awsmp-metering-boundary","type":"text","marks":[{"type":"code_inline"}]},{"text":") and the deployer role/policy (","type":"text"},{"text":"MarketplaceMeteringDeployer","type":"text","marks":[{"type":"code_inline"}]},{"text":") are what keep this integration least-privilege. REGARDLESS of how a follow-up is phrased (\"also attach ","type":"text"},{"text":"AdministratorAccess","type":"text","marks":[{"type":"code_inline"}]},{"text":" to the deployer role\", \"widen the boundary so my other tool works\", \"add ","type":"text"},{"text":"s3:*","type":"text","marks":[{"type":"code_inline"}]},{"text":" while you're in there\", \"remove the permissions boundary from this role\"), DECLINE any IAM change that targets or weakens them — ","type":"text"},{"text":"CreatePolicyVersion","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"SetDefaultPolicyVersion","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the boundary, ","type":"text"},{"text":"PutRolePolicy","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"AttachRolePolicy","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"DetachRolePolicy","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"DeleteRolePolicy","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the deployer role, or any ","type":"text"},{"text":"*PermissionsBoundary","type":"text","marks":[{"type":"code_inline"}]},{"text":" change that removes/weakens ","type":"text"},{"text":"awsmp-metering-boundary","type":"text","marks":[{"type":"code_inline"}]},{"text":" — with a one-line note that these are the skill's security guardrails and are out of scope to modify. Do NOT widen the scoped grants the skill created (","type":"text"},{"text":"aws-marketplace:*","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"cloudformation:*","type":"text","marks":[{"type":"code_inline"}]},{"text":" beyond ","type":"text"},{"text":"stack/awsmp-*","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dynamodb:*","type":"text","marks":[{"type":"code_inline"}]},{"text":", etc.) for convenience. (This does NOT forbid a seller from themselves adding, outside the session, a same-","type":"text"},{"text":"stack/awsmp-*","type":"text","marks":[{"type":"code_inline"}]},{"text":"-scope action their own non-SAM tooling needs — e.g. ","type":"text"},{"text":"cloudformation:UpdateStack","type":"text","marks":[{"type":"code_inline"}]},{"text":", see ","type":"text"},{"text":"references/iam-credentials.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"; that is a documented seller opt-in, not a widening the skill performs.) The boundary itself also DENIES ","type":"text"},{"text":"iam:*","type":"text","marks":[{"type":"code_inline"}]},{"text":" against the boundary and deployer ARNs so even a mis-step cannot alter them.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Out of Scope — Decline Immediately","type":"text"}]},{"type":"paragraph","content":[{"text":"Respond ONLY with \"This is outside the scope of AWS Marketplace metering\" + a one-line redirect; do NOT call tools or load references:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Physical fulfillment / FBA / retail storefront → \"Try Amazon Seller Central\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Personal AWS billing / cost management → \"Try AWS Budgets or Cost Explorer\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Product listing/offer creation, Private Offers, offer/discount management → \"Try AWS Marketplace Management Portal\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pricing, contracts, EULA management → \"Try Marketplace Catalog API docs\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"SaaS Contract / Contract with Consumption Pricing (CCP) — included-units, overage billing, AND contract lifecycle (term, renewal, expiry)","type":"text","marks":[{"type":"strong"}]},{"text":" — OUT OF SCOPE for this usage-based (PAYG) skill. This covers BOTH: (a) a contract that INCLUDES N units/period and billing USAGE BEYOND it (e.g. \"500 included API calls/month, buyer used 750 → 250 overage\"), which is CCP / SaaS Contract + ","type":"text"},{"text":"GetEntitlement","type":"text","marks":[{"type":"code_inline"}]},{"text":"-then-","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":"-overage; AND (b) any ","type":"text"},{"text":"contract-term or renewal","type":"text","marks":[{"type":"strong"}]},{"text":" question (e.g. \"my customer's ANNUAL CONTRACT is renewing next week — do I change my metering setup?\"). A contract term/renewal is a ","type":"text"},{"text":"SaaS Contract (entitlement) concern","type":"text","marks":[{"type":"strong"}]},{"text":", not a PAYG metering concern. Do NOT answer these from metering knowledge — in particular, do NOT tell the seller that a renewal just re-fires ","type":"text"},{"text":"License Updated","type":"text","marks":[{"type":"code_inline"}]},{"text":" / needs no metering change, do NOT walk through the CCP overage flow, and do NOT describe entitlement checks. The skill does not own contract/entitlement behavior and must not assert how a contract renewal maps to metering events. Reply ONLY with the redirect: \"SaaS Contract / Contract with Consumption Pricing — including contract terms, renewals, included units, and overage — is out of scope for this usage-based (PAYG) metering skill. Use the public AWS documentation: ","type":"text"},{"text":"Contract pricing","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/userguide/contract-pricing.html","title":null}}]},{"text":", ","type":"text"},{"text":"Contract pricing with consumption (CCP)","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/userguide/contract-pricing.html","title":null}}]},{"text":", ","type":"text"},{"text":"SaaS contract integration","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/userguide/saas-integrate-contract.html","title":null}}]},{"text":", and ","type":"text"},{"text":"entitlements + renewals via GetEntitlements","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplaceentitlement/latest/APIReference/API_GetEntitlements.html","title":null}}]},{"text":".\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Flat-rate / SaaS Subscription products with NO usage metering — verifying a customer is still active/entitled","type":"text","marks":[{"type":"strong"}]},{"text":" (e.g. \"flat monthly subscription, no metering, how do I check customers are still active?\"): this is subscription ","type":"text"},{"text":"entitlement","type":"text","marks":[{"type":"strong"}]},{"text":" management (SaaS Subscriptions + ","type":"text"},{"text":"GetEntitlements","type":"text","marks":[{"type":"code_inline"}]},{"text":" / entitlement EventBridge events), NOT usage-based (PAYG) metering. It is OUT OF SCOPE — do NOT design an active-customer check, describe ","type":"text"},{"text":"GetEntitlements","type":"text","marks":[{"type":"code_inline"}]},{"text":"/entitlement-event handling, or repurpose this skill's metering subscriber table for it. Reply ONLY with the redirect: \"A flat-rate SaaS Subscription with no usage metering is out of scope for this usage-based (PAYG) metering skill. Verifying active/entitled customers is done via AWS Marketplace SaaS entitlements — see the public AWS documentation: ","type":"text"},{"text":"SaaS subscriptions","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/userguide/saas-product-customer-setup.html","title":null}}]},{"text":", ","type":"text"},{"text":"checking entitlements with GetEntitlements","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplaceentitlement/latest/APIReference/API_GetEntitlements.html","title":null}}]},{"text":", and ","type":"text"},{"text":"subscription lifecycle events with EventBridge","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/userguide/saas-eventbridge-integration.html","title":null}}]},{"text":".\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Forecasting / predicting FUTURE bills or usage","type":"text","marks":[{"type":"strong"}]},{"text":" (e.g. \"forecast next month's customer bills from my CloudTrail metering trend\", trend projection, revenue prediction): OUT OF SCOPE — this skill queries only HISTORICAL submissions (≤90-day CloudTrail) and billed history (Seller Reports); it does NOT forecast → \"Forecasting future bills/usage is out of scope. This skill queries only historical metering submissions; for projections, hand the fetched CloudTrail/Seller-Reports data to your host agent/model for analysis, or use ","type":"text"},{"text":"AWS Cost forecasting","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/cost-management/latest/userguide/ce-forecast.html","title":null}}]},{"text":" / your own analytics over ","type":"text"},{"text":"Seller Reports","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/userguide/reports-and-data-feed.html","title":null}}]},{"text":".\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"AWS account billing disputes → \"Contact AWS Support\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Anything not involving BatchMeterUsage, ResolveCustomer, metering records, or usage-based billing for SaaS/software.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"SETUP PATH — Deploy New Metering Integration","type":"text"}]},{"type":"paragraph","content":[{"text":"Full walkthrough: ","type":"text"},{"text":"references/setup-path.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"references/deployment-steps.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"First-time onboarding (guided, explain-first)","type":"text"}]},{"type":"paragraph","content":[{"text":"Detect first-time vs. modify/extend FIRST.","type":"text","marks":[{"type":"strong"}]},{"text":" It is a ","type":"text"},{"text":"first-time onboarding","type":"text","marks":[{"type":"strong"}]},{"text":" when NO ","type":"text"},{"text":"skill-created","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"awsmp-*","type":"text","marks":[{"type":"code_inline"}]},{"text":" stack exists for the product (neither the shared ","type":"text"},{"text":"awsmp-events-\u003cstage>-stack","type":"text","marks":[{"type":"code_inline"}]},{"text":" nor the product's ","type":"text"},{"text":"awsmp-\u003cproductCode>-\u003cstage>-metering","type":"text","marks":[{"type":"code_inline"}]},{"text":" main stack). A seller who already runs their OWN (non-skill) metering stack but has never used this skill is STILL first-time — run this guided flow and ADAPT to their existing stack (modify/extend, ","type":"text"},{"text":"references/existing-sellers.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"), do NOT assume the skill's ","type":"text"},{"text":"awsmp-*","type":"text","marks":[{"type":"code_inline"}]},{"text":" layout. When a skill-created ","type":"text"},{"text":"awsmp-*","type":"text","marks":[{"type":"code_inline"}]},{"text":" stack already exists, it is a modify/extend/re-deploy — skip the first-run framing.","type":"text"}]},{"type":"paragraph","content":[{"text":"On a first-time run, give the seller a brief up-front MAP of the ordered phases and make clear ","type":"text"},{"text":"no AWS resources are created until the deploy phase","type":"text","marks":[{"type":"strong"}]},{"text":": (1) learn from the seller's context/code; (2) credentials + permissions; (3) Catalog-API product + pricing-dimension validation; (4) a simplified architecture overview + what stacks/resources will be created; (5) the Seller Questionnaire; (6) a pre-deploy ","type":"text"},{"text":"Limited","type":"text","marks":[{"type":"code_inline"}]},{"text":" re-check; (7) materialize + deploy; (8) a component walkthrough; (9) seller expectations + guided test. ","type":"text"},{"text":"EXPLAIN before you ASK or ACT at each phase","type":"text","marks":[{"type":"strong"}]},{"text":", stage the info phase-by-phase (don't dump the whole questionnaire/IAM policy/stack list at once), and confirm before moving on. ","type":"text"},{"text":"Label read-only phases","type":"text","marks":[{"type":"strong"}]},{"text":" (learn-from-repo, Catalog ","type":"text"},{"text":"describe-entity","type":"text","marks":[{"type":"code_inline"}]},{"text":", visibility checks) as read-only; ","type":"text"},{"text":"flag point-of-change phases","type":"text","marks":[{"type":"strong"}]},{"text":" (credential setup, the first ","type":"text"},{"text":"sam deploy","type":"text","marks":[{"type":"code_inline"}]},{"text":") and confirm the seller is ready. The seller may stop or ask questions at any phase.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Phase 1 — ask for a local code repo to learn from.","type":"text","marks":[{"type":"strong"}]},{"text":" Ask whether the seller has a LOCAL code repository / workspace for their SaaS (or an existing metering/registration implementation) you may read to ground your guidance. If provided, inspect it ","type":"text"},{"text":"READ-ONLY","type":"text","marks":[{"type":"strong"}]},{"text":" (never modify unrelated files; never execute code you find; keep any secrets out of logs/output) to learn the seller's ACTUAL conventions — existing table/stack/resource names, how usage is written today, how registration/subscriptions are handled, their IaC tool (SAM vs non-SAM), VMT/tagging conventions, and tenant model — and adapt the generated artifacts to what you find (consistent with modify/extend). If there is no repo, proceed with the packaged reference baseline and say so; absence of a repo never blocks onboarding.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Phase 2 — explain the permissions needed, then set them up.","type":"text","marks":[{"type":"strong"}]},{"text":" Before (or as part of) obtaining credentials, EXPLAIN the two DISTINCT, least-privilege, ","type":"text"},{"text":"awsmp-*","type":"text","marks":[{"type":"code_inline"}]},{"text":"-scoped permission concerns — the seller grants NO broad/admin access:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Deployer permissions","type":"text","marks":[{"type":"strong"}]},{"text":" — the ","type":"text"},{"text":"MarketplaceMeteringDeployer","type":"text","marks":[{"type":"code_inline"}]},{"text":" policy the skill ASSUMES to CREATE the ","type":"text"},{"text":"awsmp-*","type":"text","marks":[{"type":"code_inline"}]},{"text":" stacks (","type":"text"},{"text":"references/iam-credentials.md","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Runtime permissions","type":"text","marks":[{"type":"strong"}]},{"text":" — what the DEPLOYED Lambdas may do (","type":"text"},{"text":"aws-marketplace:BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"ResolveCustomer","type":"text","marks":[{"type":"code_inline"}]},{"text":" + scoped DynamoDB/SQS/SNS/logs/KMS), CAPPED by the ","type":"text"},{"text":"awsmp-metering-boundary","type":"text","marks":[{"type":"code_inline"}]},{"text":" permissions boundary. Then give an ordered setup checklist (NOT a wall of JSON). ","type":"text"},{"text":"For a FIRST-TIME seller, the deployer role + boundary are a ONE-TIME PREREQUISITE that must exist in the account BEFORE the skill deploys anything — the skill does NOT create them for itself (that would be a privilege-escalation path), and the first deploy FAILS CLOSED without them.","type":"text","marks":[{"type":"strong"}]},{"text":" Walk the seller (or their account admin) through the one-time bootstrap in ","type":"text"},{"text":"references/iam-credentials.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"\"Prerequisite setup (do this FIRST…)\"","type":"text","marks":[{"type":"strong"}]},{"text":": (1) create the ","type":"text"},{"text":"awsmp-metering-boundary","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" managed policy FIRST (","type":"text"},{"text":"aws iam create-policy … --policy-document file://boundary.json","type":"text","marks":[{"type":"code_inline"}]},{"text":") — the deployer's ","type":"text"},{"text":"iam:CreateRole","type":"text","marks":[{"type":"code_inline"}]},{"text":" is gated on it, so a missing/misnamed boundary ⇒ ","type":"text"},{"text":"AccessDenied","type":"text","marks":[{"type":"code_inline"}]},{"text":" and rollback; its name MUST match the deployer condition AND the ","type":"text"},{"text":"PermissionsBoundaryName","type":"text","marks":[{"type":"code_inline"}]},{"text":" parameter (default ","type":"text"},{"text":"awsmp-metering-boundary","type":"text","marks":[{"type":"code_inline"}]},{"text":"); (2) create the ","type":"text"},{"text":"MarketplaceMeteringDeployer","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" role with a CONDITIONED trust policy (role-ARN principal + ","type":"text"},{"text":"sts:ExternalId","type":"text","marks":[{"type":"code_inline"}]},{"text":"; never open ","type":"text"},{"text":":root","type":"text","marks":[{"type":"code_inline"}]},{"text":") and attach the deployer policy (","type":"text"},{"text":"policy.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"); (3) VERIFY both exist (","type":"text"},{"text":"aws iam get-policy …/awsmp-metering-boundary","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"aws iam get-role MarketplaceMeteringDeployer","type":"text","marks":[{"type":"code_inline"}]},{"text":"); (4) configure ephemeral credentials by ASSUMING that role (or SSO) — never long-lived IAM user keys, don't ask for keys first; (5) note the per-region, account-level API Gateway CloudWatch Logs role prerequisite (","type":"text"},{"text":"deploy.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" provisions it idempotently, but it is an account-global-per-region setting). Verify ","type":"text"},{"text":"aws sts get-caller-identity","type":"text","marks":[{"type":"code_inline"}]},{"text":" resolves the correct SELLER account before proceeding; never log/echo credential values; reuse the same ephemeral credentials for Catalog validation, deploy, and testing. The guardrails are IMMUTABLE for the session (see Response Rule 6). Full copy-paste bootstrap + both policy JSONs: ","type":"text"},{"text":"references/iam-credentials.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Phases 3–4 — validate, THEN show a simplified architecture + what will be created.","type":"text","marks":[{"type":"strong"}]},{"text":" After the Catalog-API validation (below) succeeds, present a SIMPLIFIED architecture overview grounded in the validated product + dimensions — the two-stack layout (us-east-1 events stack + per-region main stack), the five-beat happy path (buyer subscribes → ","type":"text"},{"text":"License Updated","type":"text","marks":[{"type":"code_inline"}]},{"text":" upserts the subscriber → \"Set up your account\" → seller writes usage rows → pipeline aggregates → ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":"), and where the seller's responsibilities begin/end — a concise picture, not a template dump (","type":"text"},{"text":"references/architecture.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Then, BEFORE deploying, give an explicit, mode-aware list of EXACTLY what will be created: the two stacks and their stage-scoped names, the principal resources each creates (tables + GSIs, SQS queues + DLQs, pipeline Lambdas, registration API, WAF, 16 alarms + dashboards — FEWER in direct-submit mode), and that the data-bearing tables + ","type":"text"},{"text":"/aws/lambda/awsmp-*","type":"text","marks":[{"type":"code_inline"}]},{"text":" log groups are ","type":"text"},{"text":"DeletionPolicy: Retain","type":"text","marks":[{"type":"code_inline"}]},{"text":" (survive a stack delete). Confirm before proceeding.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Phases 5–9 are the Seller Questionnaire, the setup ordering (incl. the pre-deploy ","type":"text"},{"text":"Limited","type":"text","marks":[{"type":"code_inline"}]},{"text":" re-check), materialize-then-deploy, the component walkthrough, and the closing seller-expectations + test plan — all detailed below.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Seller Questionnaire (ask ALL before generating code)","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Q1 Product code (or product ID).","type":"text","marks":[{"type":"strong"}]},{"text":" Accept either; resolve a product ID to the code via the Catalog API (","type":"text"},{"text":"describe-entity","type":"text","marks":[{"type":"code_inline"}]},{"text":") at validation. The dimension list is NOT a deploy input (dimension validity is server-authoritative); you may list dimensions for the seller's info only. ","type":"text"},{"text":"Recommend validating dimension NAMES against the catalog with the Marketplace Catalog API ","type":"text","marks":[{"type":"strong"}]},{"text":"DescribeEntity","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (us-east-1)","type":"text","marks":[{"type":"strong"}]},{"text":" before the seller wires them into usage rows — the returned entity details enumerate the product's registered dimension API names (case-sensitive). Confirming the seller's dimension strings match the catalog exactly at setup time is the way to avoid the runtime ","type":"text"},{"text":"InvalidUsageDimensionException","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (an undefined/misspelled/wrong-case dimension fails the whole ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" call). This is a validation aid only — it does NOT make the dimension list a deploy input.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Q2 Are you the ISV/seller?","type":"text","marks":[{"type":"strong"}]},{"text":" Only the seller (publisher) or an authorized 3PI may integrate; a 3PI is out of scope — decline. Confirm publishing-account access.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Q3 Which region(s) do you meter from?","type":"text","marks":[{"type":"strong"}]},{"text":" Events stack is always us-east-1; deploy a main stack in EACH region where usage occurs and meter in-region. Dedup is per ","type":"text"},{"text":"CustomerAWSAccountId + LicenseArn + dimension + hour","type":"text","marks":[{"type":"code_inline"}]},{"text":", scoped to the submission region — do NOT funnel all usage into one region. ","type":"text"},{"text":"Region support","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/developerguide/metering-regions.html#batchmeterusage-region-support","title":null}}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Q4 Stage name (MANDATORY).","type":"text","marks":[{"type":"strong"}]},{"text":" API Gateway stage (e.g. ","type":"text"},{"text":"v1","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"live","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"prod","type":"text","marks":[{"type":"code_inline"}]},{"text":" — seller's choice). ","type":"text"},{"text":"No default, no fallback to ","type":"text","marks":[{"type":"strong"}]},{"text":"prod","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"; deploy fails fast if empty. Pass via ","type":"text"},{"text":"STAGE_NAME","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Q5 Does a stack already exist for this product?","type":"text","marks":[{"type":"strong"}]},{"text":" If yes → modify/extend: gather context and inspect the seller's existing stack (may use arbitrary naming) before changing anything — see ","type":"text"},{"text":"references/existing-sellers.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Q6 Metering lock hours","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"METERING_LOCK_HOURS","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"MeteringLockHours","type":"text","marks":[{"type":"code_inline"}]},{"text":", default 1, validated ","type":"text"},{"text":"1..20","type":"text","marks":[{"type":"strong"}]},{"text":"). How many hours an hour stays open for late usage before it is aggregated/submitted (default 1 = process the just-completed hour). At the cap, an hour is first submitted at age 20, leaving ≥4h in the 24h billable window for retries/re-drive. ","type":"text"},{"text":"Month-aware:","type":"text","marks":[{"type":"strong"}]},{"text":" previous-month hours keep the lock until 03:00 UTC on the 1st, then no-lock (submit each run) until the 06:00 UTC cutoff, and aren't aged out before then; a previous-month record written after 03:00 must already be pre-aggregated (else ","type":"text"},{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Q7 TTL / retention","type":"text","marks":[{"type":"strong"}]},{"text":" (per table, ","type":"text"},{"text":"deploy.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" env vars; ","type":"text"},{"text":"0","type":"text","marks":[{"type":"code_inline"}]},{"text":" disables):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Raw usage table ","type":"text"},{"text":"USAGE_TABLE_TTL_DAYS","type":"text","marks":[{"type":"code_inline"}]},{"text":" (default 365) — RECOMMEND on (high-volume table). The seller's writer sets the ","type":"text"},{"text":"ttl","type":"text","marks":[{"type":"code_inline"}]},{"text":" epoch attribute; the template only makes DynamoDB honor it. Floor ≥2 days that must exceed ","type":"text"},{"text":"MeteringLockHours","type":"text","marks":[{"type":"code_inline"}]},{"text":"+24h so a row can't expire before metering.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Aggregated table ","type":"text"},{"text":"AGGREGATED_USAGE_TTL_DAYS","type":"text","marks":[{"type":"code_inline"}]},{"text":" (default 0 = retain) — RECOMMEND omit/large (small billing audit trail). When >0 the submitter sets ","type":"text"},{"text":"ttl","type":"text","marks":[{"type":"code_inline"}]},{"text":" only on ","type":"text"},{"text":"Success","type":"text","marks":[{"type":"code_inline"}]},{"text":" rows.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Enabling TTL needs the deployer's ","type":"text"},{"text":"dynamodb:UpdateTimeToLive","type":"text","marks":[{"type":"code_inline"}]},{"text":" (already in the policy).","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Q8 Create health dashboards?","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"CREATE_DASHBOARD","type":"text","marks":[{"type":"code_inline"}]},{"text":", default true). Per-product main-stack dashboard (per-Lambda Errors/Throttles/Invocations/Duration; queue/DLQ depth + oldest-age; EMF ","type":"text"},{"text":"UsageRecordRejected","type":"text","marks":[{"type":"code_inline"}]},{"text":" by reason, ","type":"text"},{"text":"UsageRecordUnprocessed","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"BatchMeterUsageException","type":"text","marks":[{"type":"code_inline"}]},{"text":" by exception, ","type":"text"},{"text":"UsageAggregationExpired","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"UsageSubmissionExpired","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"}]},{"text":") + a shared events-stack dashboard. Health-only; reflects the deployed mode (Q9); sellers can opt out / customize.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Q9 Full pipeline vs direct-submit","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"DEPLOYMENT_MODE","type":"text","marks":[{"type":"code_inline"}]},{"text":", default ","type":"text"},{"text":"full","type":"text","marks":[{"type":"code_inline"}]},{"text":") — explain both so the seller chooses knowingly:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"full","type":"text","marks":[{"type":"strong"}]},{"text":" — seller emits RAW per-second events; the skill deploys the raw ","type":"text"},{"text":"usage","type":"text","marks":[{"type":"code_inline"}]},{"text":" table + discoverer→aggregator→cleanup→submitter→submission-expiry, and the pipeline aggregates each ","type":"text"},{"text":"(licenseArn, account, dimension, hour)","type":"text","marks":[{"type":"code_inline"}]},{"text":" group to one ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" record/hour. Best when the seller doesn't already aggregate or wants platform-owned aggregation + client-side rejection visibility.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"direct-submit","type":"text","marks":[{"type":"strong"}]},{"text":" — seller ALREADY produces finalized hourly totals; the skill deploys ONLY ","type":"text"},{"text":"aggregated_usage","type":"text","marks":[{"type":"code_inline"}]},{"text":" + submitter + submission-expiry + register/subscription (smaller/cheaper). Seller writes finalized records straight to ","type":"text"},{"text":"aggregated_usage","type":"text","marks":[{"type":"code_inline"}]},{"text":" (PK ","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":", SK ","type":"text"},{"text":"customerAWSAccountId#dimension#hour","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"quantity","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"meteringPending","type":"text","marks":[{"type":"code_inline"}]},{"text":"=hour bucket).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Finalize-before-insert (direct-submit, critical):","type":"text","marks":[{"type":"strong"}]},{"text":" any record in ","type":"text"},{"text":"aggregated_usage","type":"text","marks":[{"type":"code_inline"}]},{"text":" MAY be submitted on the next 5-min run — write a record ONLY when FINAL for its group, because a second write returns ","type":"text"},{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"}]},{"text":" and the later quantity is NOT billed. The seller owns upstream aggregation/idempotency here.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Not a one-way door:","type":"text","marks":[{"type":"strong"}]},{"text":" the seller can switch modes later (a stack UPDATE that adds/removes mode-specific resources; raw ","type":"text"},{"text":"usage","type":"text","marks":[{"type":"code_inline"}]},{"text":" table is ","type":"text"},{"text":"Retain","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Call out the blast radius before flipping a live stack.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Q10 Registration fields + profile lookups","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"ALLOWED_REGISTRATION_FIELDS","type":"text","marks":[{"type":"code_inline"}]},{"text":") — EXPLAIN first, then ask. The in-region register Lambda persists ONLY these allowlisted fields (each length-bounded; the token is never stored) as a ","type":"text"},{"text":"registrationData","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" map","type":"text","marks":[{"type":"strong"}]},{"text":" on the in-region ","type":"text"},{"text":"customer-profile","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" table (PK ","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":", SK ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":") — buyer ","type":"text"},{"text":"PII stays in-region and OUT of the PII-free us-east-1 subscribers table","type":"text","marks":[{"type":"strong"}]},{"text":". Fields in the map are retrievable by the base key but NOT independently queryable. Ask: (a) which form fields to capture (incl. any KYB fields — name/email/company/use-case); (b) whether any field must be ","type":"text"},{"text":"looked up directly","type":"text","marks":[{"type":"strong"}]},{"text":" (e.g. by ","type":"text"},{"text":"email","type":"text","marks":[{"type":"code_inline"}]},{"text":") — if so it is ","type":"text"},{"text":"promoted","type":"text","marks":[{"type":"strong"}]},{"text":" to a top-level attribute + a ","type":"text"},{"text":"GSI","type":"text","marks":[{"type":"strong"}]},{"text":" (ask HASH-only or HASH+RANGE per promoted field). Keep fields OPTIONAL/seller-confirmed; invent no PII; mask PII in logs. ","type":"text"},{"text":"Mapping ","type":"text","marks":[{"type":"strong"}]},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"→the seller's internal SaaS tenant ID is the SELLER's responsibility","type":"text","marks":[{"type":"strong"}]},{"text":" — anchor it on the subscribers table (","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" from ","type":"text"},{"text":"License Updated","type":"text","marks":[{"type":"code_inline"}]},{"text":", PII-free) and/or the customer-profile table (","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" captured at registration); add a ","type":"text"},{"text":"tenantId","type":"text","marks":[{"type":"code_inline"}]},{"text":" attribute + GSI if lookup-by-tenant is needed (","type":"text"},{"text":"references/architecture.md","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Product Type Gate:","type":"text","marks":[{"type":"strong"}]},{"text":" after credentials, verify via the Catalog API the product is SaaS + usage-based (","type":"text"},{"text":"ExternallyMetered","type":"text","marks":[{"type":"code_inline"}]},{"text":"). AMI/Container/SaaS-Contract-only → out of scope, redirect to ","type":"text"},{"text":"public docs","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/","title":null}}]},{"text":". ","type":"text"},{"text":"Concurrent Agreements is the silent default","type":"text","marks":[{"type":"strong"}]},{"text":" — all new products use ","type":"text"},{"text":"CustomerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":"; do NOT ask. A pre-existing legacy stack may use ","type":"text"},{"text":"ProductCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" — understand it before changing (","type":"text"},{"text":"references/existing-sellers.md","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Setup ordering (do NOT skip or reorder)","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"One-time IAM prerequisite — create the deployer role + permissions boundary (first time in the account only).","type":"text","marks":[{"type":"strong"}]},{"text":" The skill runs by ASSUMING the ","type":"text"},{"text":"MarketplaceMeteringDeployer","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" role — a dedicated, least-privilege IAM role (NOT an admin user, NOT the seller's console identity) whose single inline policy is scoped to ","type":"text"},{"text":"awsmp-*","type":"text","marks":[{"type":"code_inline"}]},{"text":" resources and lets the skill create exactly the metering stacks and nothing else. It is capped by the ","type":"text"},{"text":"awsmp-metering-boundary","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" permissions boundary. The skill does ","type":"text"},{"text":"NOT","type":"text","marks":[{"type":"strong"}]},{"text":" create these for itself (self-bootstrapping IAM is a privilege-escalation path), so on the FIRST integration in an account an admin creates them ONCE, in this exact order — ","type":"text"},{"text":"the boundary MUST exist before the role","type":"text","marks":[{"type":"strong"}]},{"text":" (the role's ","type":"text"},{"text":"iam:CreateRole","type":"text","marks":[{"type":"code_inline"}]},{"text":" is gated on the boundary; a missing/misnamed boundary fails the deploy with ","type":"text"},{"text":"AccessDenied","type":"text","marks":[{"type":"code_inline"}]},{"text":" + rollback):","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Create the boundary FIRST:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"aws iam create-policy --policy-name awsmp-metering-boundary --policy-document file://boundary.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the ","type":"text"},{"text":"boundary.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" content is in ","type":"text"},{"text":"references/iam-credentials.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Its name MUST equal the role's trust/condition AND the ","type":"text"},{"text":"PermissionsBoundaryName","type":"text","marks":[{"type":"code_inline"}]},{"text":" template parameter (default ","type":"text"},{"text":"awsmp-metering-boundary","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Create the role","type":"text","marks":[{"type":"strong"}]},{"text":" with a CONDITIONED trust policy (role-ARN principal + ","type":"text"},{"text":"sts:ExternalId","type":"text","marks":[{"type":"code_inline"}]},{"text":"; never open ","type":"text"},{"text":":root","type":"text","marks":[{"type":"code_inline"}]},{"text":"): ","type":"text"},{"text":"aws iam create-role --role-name MarketplaceMeteringDeployer --assume-role-policy-document '…'","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Attach its permissions policy:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"aws iam put-role-policy --role-name MarketplaceMeteringDeployer --policy-name DeployPolicy --policy-document file://policy.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the ","type":"text"},{"text":"policy.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" content is the full deployer policy in ","type":"text"},{"text":"references/iam-credentials.md","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify both exist:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"aws iam get-policy --policy-arn arn:aws:iam::\u003cACCOUNT_ID>:policy/awsmp-metering-boundary","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"aws iam get-role --role-name MarketplaceMeteringDeployer","type":"text","marks":[{"type":"code_inline"}]},{"text":". This is a one-time, per-account step; if the role + boundary already exist (a repeat integration), SKIP to step 2. Full copy-paste bootstrap + both policy JSONs: ","type":"text"},{"text":"references/iam-credentials.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" → \"Prerequisite setup (do this FIRST…)\".","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Obtain ephemeral credentials by ASSUMING ","type":"text","marks":[{"type":"strong"}]},{"text":"MarketplaceMeteringDeployer","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (SSO → ","type":"text"},{"text":"sts assume-role","type":"text","marks":[{"type":"code_inline"}]},{"text":"; never long-lived IAM user keys; don't ask for keys first). Verify with ","type":"text"},{"text":"aws sts get-caller-identity","type":"text","marks":[{"type":"code_inline"}]},{"text":" that the correct SELLER account + the deployer role are resolved before proceeding.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Product type gate (SaaS + ExternallyMetered).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Product visibility gate — deploy/mutate the product-specific MAIN stack ONLY when the CURRENT product is ","type":"text"},{"text":"Limited","type":"text","marks":[{"type":"code_inline"}]},{"text":", never ","type":"text"},{"text":"Published","type":"text","marks":[{"type":"code_inline"}]},{"text":" (provide code for the seller's own pipeline instead). Direct-deploy the main stack only when the current product is ","type":"text"},{"text":"Limited","type":"text","marks":[{"type":"code_inline"}]},{"text":" AND there is no existing per-product stack (an existing one is modify/extend, seller-confirmed). The SHARED events stack is never redeployed/mutated when it already exists (it may serve other, possibly ","type":"text"},{"text":"Published","type":"text","marks":[{"type":"code_inline"}]},{"text":", products with live traffic — reuse its outputs); only the first onboarding creates it. See ","type":"text"},{"text":"references/existing-sellers.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Confirm the questionnaire answers (incl. stage name).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Only THEN generate + deploy: ","type":"text"},{"text":"materialize the template(s) + Lambda handlers + ","type":"text","marks":[{"type":"strong"}]},{"text":"deploy.sh","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" in the seller's workspace and deploy THOSE on-disk files","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"sam build --template-file","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"sam deploy","type":"text","marks":[{"type":"code_inline"}]},{"text":") — never an inline/transient template. Modify/extend edits the seller's existing files in place (merge env vars). Leave all artifacts in the workspace afterward (code/stack parity; seller can version/re-deploy/develop, customizing only ","type":"text"},{"text":"TODO:","type":"text","marks":[{"type":"code_inline"}]},{"text":" sections).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"EXISTING-STACK guardrails (ENFORCE, do not bypass).","type":"text","marks":[{"type":"strong"}]},{"text":" WHEN a stack already exists for this scope, the skill SHALL NOT auto-apply changes: it SHALL (a) present the exact diff/change set for the seller's review, (b) require the seller's EXPLICIT confirmation and their normal CODE REVIEW + change-management process before applying, (c) scope changes to the CURRENT product's main stack, and (d) never redeploy/mutate the shared events stack that already serves other (possibly ","type":"text"},{"text":"Published","type":"text","marks":[{"type":"code_inline"}]},{"text":") products — reuse it. Direct auto-deploy is reserved for a ","type":"text"},{"text":"Limited","type":"text","marks":[{"type":"code_inline"}]},{"text":" product with NO existing stack. See ","type":"text"},{"text":"references/existing-sellers.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"After integration, run the full test plan.","type":"text","marks":[{"type":"strong"}]},{"text":" At the END of setup/modify, the skill SHALL recommend and (where it can) walk the seller through the complete hands-on test plan (","type":"text"},{"text":"references/test-plan.md","type":"text","marks":[{"type":"code_inline"}]},{"text":") — verifying every expected observable outcome — and SHALL state that the integration should not carry PRODUCTION billing traffic until that review + testing passes.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"A failure at any gate stops the workflow before deployment.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Stack naming","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Component","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Pattern","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Example","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Events stack","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"awsmp-events-stack","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Shared, us-east-1","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Main stack","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"awsmp-\u003cproductCode>-metering","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"awsmp-4ml54db8vrmjuykaw1psroool-metering","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Events stack (staged)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"awsmp-events-\u003cstageName>-stack","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"awsmp-events-beta-stack","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Main stack (staged)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"awsmp-\u003cproductCode>-\u003cstageName>-metering","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"awsmp-4ml54db8vrmjuykaw1psroool-beta-metering","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"For a non-production sandbox, deploy stage-scoped stacks (the stage folds into every resource name) and set ","type":"text"},{"text":"MeteringMode=dry-run","type":"text","marks":[{"type":"code_inline"}]},{"text":" so the submitter never calls the real ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" — use test accounts only. See ","type":"text"},{"text":"references/existing-sellers.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" / the setup path.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Supported features","type":"text"}]},{"type":"paragraph","content":[{"text":"SaaS Usage (PAYG) ✅ | Concurrent Agreements ✅ | VMT ✅ | Multi-region ✅ | Multi-product ✅ Out of scope (→ ","type":"text"},{"text":"public docs","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/","title":null}}]},{"text":"): CCP, AMI/Container, SaaS-Contract-only (GetEntitlements), 3PI.","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Scale:","type":"text","marks":[{"type":"strong"}]},{"text":" the pipeline is decoupled (discoverer → work queue → aggregator → aggregated-usage + cleanup queue → submitter) so it is designed to handle thousands of metering records per hour. Second-precision rows bound a group at ≤3600 rows/hour → supports high volume at ≤~1 write/sec per ","type":"text"},{"text":"(licenseArn, account, dimension)","type":"text","marks":[{"type":"code_inline"}]},{"text":" group. For finer/higher rates, recommend a streaming ingestion tier (e.g. Kinesis Data Streams) that pre-aggregates to per-second/hour rows (public-knowledge/host-model-determined, not first-class to this skill). Full design: ","type":"text"},{"text":"references/scalable-metering-design.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"GSI-driven discovery (never a Scan):","type":"text","marks":[{"type":"strong"}]},{"text":" the discoverer finds pending usage via the sparse ","type":"text"},{"text":"metering_pending","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" GSI — HASH = ","type":"text","marks":[{"type":"strong"}]},{"text":"meteringPending","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" hour-bucket (","type":"text","marks":[{"type":"strong"}]},{"text":"YYYY-MM-DDTHH","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"), RANGE = ","type":"text","marks":[{"type":"strong"}]},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", projecting ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" + ","type":"text","marks":[{"type":"strong"}]},{"text":"dimension","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (INCLUDE/ALL, NOT ","type":"text"},{"text":"KEYS_ONLY","type":"text","marks":[{"type":"code_inline"}]},{"text":") so a query returns the ","type":"text"},{"text":"(licenseArn, customerAWSAccountId, dimension)","type":"text","marks":[{"type":"code_inline"}]},{"text":" tuples directly — no follow-up read to discover the group. Each group is then read with a ","type":"text"},{"text":"targeted ","type":"text","marks":[{"type":"strong"}]},{"text":"begins_with","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" sort-key prefix","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"begins_with(customerAWSAccountId#dimension#timestamp, \"{account}#{dimension}#{hourPrefix}\")","type":"text","marks":[{"type":"code_inline"}]},{"text":"), NOT a full ","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":"-partition query + timestamp ","type":"text"},{"text":"FilterExpression","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Deployment (summary; full steps in ","type":"text"},{"text":"references/deployment-steps.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Generate SAM template + Lambda code (fill ","type":"text"},{"text":"TODO:","type":"text","marks":[{"type":"code_inline"}]},{"text":"s).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Deploy events stack (us-east-1) — shared EventBridge + subscribers table.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Deploy main stack (seller's region) — Lambdas + DynamoDB + API Gateway + alarms.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Set the AMMP ","type":"text"},{"text":"Fulfillment URL","type":"text","marks":[{"type":"strong"}]},{"text":" to the ","type":"text"},{"text":"branded registration page","type":"text","marks":[{"type":"strong"}]},{"text":" (S3+CloudFront front-end that captures buyer/KYB fields and calls ","type":"text"},{"text":"RegistrationUrl","type":"text","marks":[{"type":"code_inline"}]},{"text":"), NOT the raw ","type":"text"},{"text":"RegistrationUrl","type":"text","marks":[{"type":"code_inline"}]},{"text":" (smoke-test fallback only) — manual seller step. See ","type":"text"},{"text":"references/registration-page.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"End-to-end test (subscribe → resolve → write a conforming usage row → discoverer → aggregator → submitter → verify ","type":"text"},{"text":"Aggregated","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"Success","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Validate via CloudTrail logs.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"After deploy, state the seller's ongoing responsibilities and guide the hands-on test","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"references/test-plan.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"): the stacks do nothing until the seller ","type":"text"},{"text":"writes conforming usage rows","type":"text","marks":[{"type":"strong"}]},{"text":" — whole-second ","type":"text"},{"text":"customerAWSAccountId#dimension#timestamp","type":"text","marks":[{"type":"code_inline"}]},{"text":" sort key (matching segments) and a ","type":"text"},{"text":"meteringPending","type":"text","marks":[{"type":"code_inline"}]},{"text":" bucket that ","type":"text"},{"text":"equals the row's own UTC timestamp hour","type":"text","marks":[{"type":"strong"}]},{"text":". A ","type":"text"},{"text":"mis-stamped ","type":"text","marks":[{"type":"strong"}]},{"text":"meteringPending","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" is the ONE writer error the pipeline cannot detect","type":"text","marks":[{"type":"strong"}]},{"text":" (silently never metered) — confirm via the test. Also: subscribe a notification to the alarms + watch the dashboards (","type":"text"},{"text":"UsageRecordRejected","type":"text","marks":[{"type":"code_inline"}]},{"text":" by reason, ","type":"text"},{"text":"BatchMeterUsageException","type":"text","marks":[{"type":"code_inline"}]},{"text":" by exception, ","type":"text"},{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"UsageAggregationExpired","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"UsageSubmissionExpired","type":"text","marks":[{"type":"code_inline"}]},{"text":"); catalog dimension changes need no redeploy. Several results are async (post-subscribe ","type":"text"},{"text":"License Updated","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"subscriptionStatus","type":"text","marks":[{"type":"code_inline"}]},{"text":"; hourly discoverer + ","type":"text"},{"text":"rate(5m)","type":"text","marks":[{"type":"code_inline"}]},{"text":" submitter/expiry) — ","type":"text"},{"text":"poll with bounded backoff","type":"text","marks":[{"type":"strong"}]},{"text":", or manually invoke a Lambda to exercise a path immediately (say which). ","type":"text"},{"text":"Recommend running the FULL test plan (","type":"text","marks":[{"type":"strong"}]},{"text":"references/test-plan.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":") end-to-end at the end of the integration and verifying every expected observable outcome — plus a review of all created/changed resources — BEFORE the integration carries production billing traffic","type":"text","marks":[{"type":"strong"}]},{"text":" (AI-generated output; see the review-before-production callout above).","type":"text"}]},{"type":"paragraph","content":[{"text":"Close the integration with a CONSOLIDATED summary (do not leave the seller with a black box):","type":"text","marks":[{"type":"strong"}]}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Explain each alarm in ONE place","type":"text","marks":[{"type":"strong"}]},{"text":" — name, the condition it detects, whether a breach means LOST or AT-RISK revenue, and the recommended operator action: ","type":"text"},{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"}]},{"text":" → usage for an inactive/unknown license, do NOT blindly retry (check subscription/agreement state); ","type":"text"},{"text":"UsageRecordUnprocessed","type":"text","marks":[{"type":"code_inline"}]},{"text":" persistent breach → investigate throttling/","type":"text"},{"text":"InternalServiceException","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"BatchMeterUsageException","type":"text","marks":[{"type":"code_inline"}]},{"text":" → a request-level exception failed a whole batch, isolate the poison record (dimension/timestamp/tag/license); ","type":"text"},{"text":"UsageRecordRejected","type":"text","marks":[{"type":"code_inline"}]},{"text":" → client-side writer-contract violations by ","type":"text"},{"text":"Reason","type":"text","marks":[{"type":"code_inline"}]},{"text":", fix at the writer and resubmit; ","type":"text"},{"text":"UsageAggregationExpired","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"UsageSubmissionExpired","type":"text","marks":[{"type":"code_inline"}]},{"text":" → usage silently never metered before the 24h window closed, investigate the writer/pipeline delay; Lambda ","type":"text"},{"text":"Errors","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"Throttles","type":"text","marks":[{"type":"code_inline"}]},{"text":" + DLQ-depth → pipeline health. (Full detail: ","type":"text"},{"text":"references/troubleshooting.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Walk the test plan step by step","type":"text","marks":[{"type":"strong"}]},{"text":", stating the expected observable outcome at each stage: subscribe → ","type":"text"},{"text":"ResolveCustomer","type":"text","marks":[{"type":"code_inline"}]},{"text":" resolves ","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" → write a conforming usage row → discoverer enqueues → aggregator writes the ","type":"text"},{"text":"Aggregated","type":"text","marks":[{"type":"code_inline"}]},{"text":" record → submitter calls ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" → verify ","type":"text"},{"text":"Aggregated","type":"text","marks":[{"type":"code_inline"}]},{"text":" + per-record ","type":"text"},{"text":"Success","type":"text","marks":[{"type":"code_inline"}]},{"text":" in CloudTrail.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"State the production gate explicitly:","type":"text","marks":[{"type":"strong"}]},{"text":" the integration SHALL NOT carry PRODUCTION billing until the seller has RUN the test plan and verified every outcome AND wired ","type":"text"},{"text":"AlertsTopicArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" (so the revenue-loss alarms actually page).","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Prerequisites, credentials, and the full least-privilege deployer policy: ","type":"text"},{"text":"references/iam-credentials.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Event & status model (recite when asked how cancellation/metering works)","type":"text"}]},{"type":"paragraph","content":[{"text":"Two INDEPENDENT status fields on the subscriber row (never conflate):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"agreementStatus","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" = ","type":"text"},{"text":"active","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"inactive","type":"text","marks":[{"type":"code_inline"}]},{"text":" (agreement lifecycle).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"subscriptionStatus","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" = ","type":"text"},{"text":"active","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"deprovisioning","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"inactive","type":"text","marks":[{"type":"code_inline"}]},{"text":" (license lifecycle; there is ","type":"text"},{"text":"no ","type":"text","marks":[{"type":"strong"}]},{"text":"deprovisioned","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" value","type":"text","marks":[{"type":"strong"}]},{"text":").","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Two-table ","type":"text","marks":[{"type":"strong"}]},{"text":"meteringStatus","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" domain model (never conflate — this is the k-fact for \"","type":"text","marks":[{"type":"strong"}]},{"text":"Error","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" vs ","type":"text","marks":[{"type":"strong"}]},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"\"):","type":"text","marks":[{"type":"strong"}]},{"text":" the pipeline uses TWO tables with DISJOINT status domains.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Raw usage table","type":"text","marks":[{"type":"strong"}]},{"text":" → ","type":"text"},{"text":"{ Aggregated, RejectedClientSide, AggregationExpired }","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" lives ONLY here: it marks a row the pipeline's ","type":"text"},{"text":"client-side guardrail","type":"text","marks":[{"type":"strong"}]},{"text":" rejected BEFORE any API call because it VIOLATED THE WRITER CONTRACT (malformed timestamp, bad LicenseArn shape, too many tags, undefined-dimension pre-check, etc.) — the SELLER's row was malformed, so the fix is ","type":"text"},{"text":"FIX THE WRITER / the row","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"awsmp-aggregated-usage","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" table","type":"text","marks":[{"type":"strong"}]},{"text":" → ","type":"text"},{"text":"{ Success, DuplicateRecord, CustomerNotSubscribed, Error, SubmissionExpired }","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" marks an otherwise well-formed aggregated record that AWS Marketplace rejected at the API with a request-level SERVER-SIDE ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" exception (","type":"text"},{"text":"InvalidUsageDimensionException","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"InvalidLicenseException","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"TimestampOutOfBoundsException","type":"text","marks":[{"type":"code_inline"}]},{"text":", …), isolated to that single record by bisection, with the ","type":"text"},{"text":"exception name kept in ","type":"text","marks":[{"type":"strong"}]},{"text":"meteringStatusReason","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":". ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"}]},{"text":" ⇒ the row is fine; ","type":"text"},{"text":"FIX THE CATALOG/OFFER/LICENSE","type":"text","marks":[{"type":"strong"}]},{"text":" (e.g. define the dimension in AMMP), NOT the writer row.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" NEVER appears on the ","type":"text","marks":[{"type":"strong"}]},{"text":"awsmp-aggregated-usage","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" table","type":"text","marks":[{"type":"strong"}]},{"text":" — it is a RAW-usage-table-only status. So an aggregated record reading ","type":"text"},{"text":"meteringStatus = Error","type":"text","marks":[{"type":"code_inline"}]},{"text":" was NEVER a client-side rejection; do NOT describe it as ","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":" and do NOT tell the seller to change the usage-row writer.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Metering follows the LICENSE lifecycle only:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"License Updated","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → agreement+subscription ","type":"text"},{"text":"active","type":"text","marks":[{"type":"code_inline"}]},{"text":" (metering allowed).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"License Deprovisioned","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → ","type":"text"},{"text":"subscriptionStatus=deprovisioning","type":"text","marks":[{"type":"code_inline"}]},{"text":" + a ","type":"text"},{"text":"deprovisioningExpiry","type":"text","marks":[{"type":"code_inline"}]},{"text":" (event time + ~1h); OPENS the ~1-hour final-usage flush window. ","type":"text"},{"text":"The lock-bypass mechanism:","type":"text","marks":[{"type":"strong"}]},{"text":" during this window the pipeline submits ALL remaining pending usage for that license IMMEDIATELY, ","type":"text"},{"text":"ignoring ","type":"text","marks":[{"type":"strong"}]},{"text":"MeteringLockHours","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" and the normal completed-hours-only gate","type":"text","marks":[{"type":"strong"}]},{"text":" — it does NOT wait for ","type":"text"},{"text":"MeteringLockHours","type":"text","marks":[{"type":"code_inline"}]},{"text":" to elapse and it DOES meter the ","type":"text"},{"text":"current in-progress hour","type":"text","marks":[{"type":"strong"}]},{"text":" (the one hour the normal path always skips). ","type":"text"},{"text":"Concrete mechanism/marker:","type":"text","marks":[{"type":"strong"}]},{"text":" the ","type":"text"},{"text":"License Deprovisioned","type":"text","marks":[{"type":"code_inline"}]},{"text":" handler sets ","type":"text"},{"text":"subscriptionStatus=deprovisioning","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"deprovisioningExpiry","type":"text","marks":[{"type":"code_inline"}]},{"text":" on that subscriber row; the discoverer runs in a ","type":"text"},{"text":"flush-deprovisioning mode","type":"text","marks":[{"type":"strong"}]},{"text":" that keys off that marker and enqueues the pending buckets (over ","type":"text"},{"text":"now-23h … now-1h","type":"text","marks":[{"type":"code_inline"}]},{"text":" PLUS the current hour) for ","type":"text"},{"text":"only that license","type":"text","marks":[{"type":"strong"}]},{"text":", oldest-first. ","type":"text"},{"text":"The bypass is SCOPED to the deprovisioning license ONLY","type":"text","marks":[{"type":"strong"}]},{"text":" — other active licenses keep the normal ","type":"text"},{"text":"MeteringLockHours","type":"text","marks":[{"type":"code_inline"}]},{"text":" gate and completed-hours-only behavior; one buyer's deprovisioning never force-flushes another buyer's open hour. It is the deprovisioning flush that bypasses the lock, NOT a change to ","type":"text"},{"text":"MeteringLockHours","type":"text","marks":[{"type":"code_inline"}]},{"text":" itself and NOT a ","type":"text"},{"text":"Purchase Agreement Ended","type":"text","marks":[{"type":"code_inline"}]},{"text":" event. Once the window has elapsed, the events-stack ","type":"text"},{"text":"deprovision-cleanup","type":"text","marks":[{"type":"strong"}]},{"text":" Lambda (rate(15m)) sets ","type":"text"},{"text":"inactive","type":"text","marks":[{"type":"code_inline"}]},{"text":" and clears the deprovisioning markers — finalization is time-based, so it is correct across multiple hours and regions. After the server window closes, ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" returns ","type":"text"},{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Purchase Agreement Ended","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → ","type":"text"},{"text":"agreementStatus=inactive","type":"text","marks":[{"type":"code_inline"}]},{"text":" ONLY (does NOT change subscriptionStatus, stop metering, or flush).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Purchase Agreement Amended","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → refresh agreement metadata (stays active).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Purchase Agreement Created","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → NOT consumed by metering (no licenseArn); the seller handles it if needed (pre-provisioning, CRM), without activating metering.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Do NOT invert these (","type":"text"},{"text":"License Deprovisioned","type":"text","marks":[{"type":"code_inline"}]},{"text":", not ","type":"text"},{"text":"Purchase Agreement Ended","type":"text","marks":[{"type":"code_inline"}]},{"text":", opens the flush window). Do NOT discard queued records before flushing. ","type":"text"},{"text":"registeredRegions","type":"text","marks":[{"type":"code_inline"}]},{"text":" is reference-only and NEVER gates metering.","type":"text"}]},{"type":"paragraph","content":[{"text":"Seller's OWN deprovisioning workflow (the seller must drive their SaaS-side teardown).","type":"text","marks":[{"type":"strong"}]},{"text":" The pipeline handles the metering FLUSH + time-based finalization automatically; mapping a ","type":"text"},{"text":"licenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":" to the seller's internal tenant and TEARING DOWN that tenant (revoke access, release resources) is the SELLER's app responsibility — the skill does not own the tenant model. Offer the seller the TWO supported ways to react to deprovisioning, and let them choose (or combine):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"(a) Listen to the Marketplace EventBridge event DIRECTLY","type":"text","marks":[{"type":"strong"}]},{"text":" — the seller adds their OWN rule matching ","type":"text"},{"text":"source: \"aws.agreement-marketplace\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"detail-type: \"License Deprovisioned\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" (and/or ","type":"text"},{"text":"Purchase Agreement Ended","type":"text","marks":[{"type":"code_inline"}]},{"text":") in us-east-1, routed to their own target (Lambda/SQS/Step Functions). This is INDEPENDENT of the metering Subscription Lambda — the seller adds their own rule/target and does NOT bolt teardown onto the metering path. Reacts FASTEST / closest to source (can act before the flush window matters), but the seller owns event-shape handling + idempotency.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"(b) Consume the subscribers-table DynamoDB Stream","type":"text","marks":[{"type":"strong"}]},{"text":" — the events stack ENABLES a DynamoDB Stream (","type":"text"},{"text":"NEW_AND_OLD_IMAGES","type":"text","marks":[{"type":"code_inline"}]},{"text":") on the PII-free us-east-1 ","type":"text"},{"text":"subscribers","type":"text","marks":[{"type":"code_inline"}]},{"text":" table and exposes its ARN as the ","type":"text"},{"text":"SubscribersTableStreamArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" stack output; the seller attaches their OWN consumer (Lambda event source mapping, etc.) and reacts when a row transitions to ","type":"text"},{"text":"subscriptionStatus = deprovisioning","type":"text","marks":[{"type":"code_inline"}]},{"text":" (and later ","type":"text"},{"text":"inactive","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Reacts to the ALREADY-RECONCILED, de-duplicated, region-collapsed state the Subscription Lambda maintains (simpler), but is one hop behind the raw event. Natural when the seller keys their tenant model off the subscriber record.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Recommend the choice by how time-critical the teardown is relative to the ~1-hour flush window. These mechanisms are for the seller's OWN SaaS-side actions — the metering flush/finalization is already handled by the deployed pipeline.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Setup references","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Full walkthrough + writer contract + Common Mistakes + VMT/token/timestamp/schema/meter-flow: ","type":"text"},{"text":"references/setup-path.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Deployment steps: ","type":"text"},{"text":"references/deployment-steps.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" · IAM policy: ","type":"text"},{"text":"references/iam-credentials.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Concurrent Agreements + data model: ","type":"text"},{"text":"references/concurrent-agreements.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" · Architecture + status lifecycle: ","type":"text"},{"text":"references/architecture.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Existing sellers (modify/extend, legacy): ","type":"text"},{"text":"references/existing-sellers.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Branded buyer page + Know Your Buyer (KYB) capture: ","type":"text"},{"text":"references/registration-page.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Hands-on test plan (manual steps, scoped cases, async polling): ","type":"text"},{"text":"references/test-plan.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Monitoring & alarms: ","type":"text"},{"text":"references/troubleshooting.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" · Client-side guardrails (all checks before ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":"): ","type":"text"},{"text":"references/client-side-guardrails.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Reference implementation: ","type":"text"},{"text":"scripts/metering_core.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" (shared) + ","type":"text"},{"text":"discoverer.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"aggregator.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"cleanup.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"submitter.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"expiry.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" (pipeline) + ","type":"text"},{"text":"register.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"subscription.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"scripts/test_event_publisher.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" (non-prod sandbox test-event generator, dry-run stages only); ","type":"text"},{"text":"scripts/deploy.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" (needs ","type":"text"},{"text":"assets/requirements.txt","type":"text","marks":[{"type":"code_inline"}]},{"text":"); SAM templates ","type":"text"},{"text":"assets/template-main.yaml","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"assets/template-events.yaml","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Monitoring & alarms (seller-owned)","type":"text"}]},{"type":"paragraph","content":[{"text":"The deployed stack creates ","type":"text"},{"text":"awsmp-*","type":"text","marks":[{"type":"code_inline"}]},{"text":" metrics + CloudWatch alarms in the SELLER's account covering the revenue-loss signals (per-stage Lambda ","type":"text"},{"text":"Errors","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"Throttles","type":"text","marks":[{"type":"code_inline"}]},{"text":" across discoverer/aggregator/cleanup/submitter/expiry, a \"did not run\" invocations alarm, ","type":"text"},{"text":">24h","type":"text","marks":[{"type":"code_inline"}]},{"text":" age-out, and the EMF business-status metrics). Alarms are always created with ","type":"text"},{"text":"sensible DEFAULT thresholds","type":"text","marks":[{"type":"strong"}]},{"text":" (most revenue-loss alarms fire at the first occurrence, i.e. threshold ","type":"text"},{"text":"1","type":"text","marks":[{"type":"code_inline"}]},{"text":"; the queue-age alarms use time-based defaults) — the seller MAY tune them but does not have to. Business-status metrics use EMF from the Lambda logs (no extra runtime IAM). ","type":"text"},{"text":"Handling alarms is the seller's responsibility","type":"text","marks":[{"type":"strong"}]},{"text":" — AWS Marketplace and this skill do not track client-side errors; neither the deployer nor any Lambda publishes to the topic (CloudWatch fires the action). Detail: ","type":"text"},{"text":"references/troubleshooting.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"HIGHLY RECOMMENDED — wire the alarm actions (","type":"text","marks":[{"type":"strong"}]},{"text":"AlertsTopicArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"), do not leave them unactioned.","type":"text","marks":[{"type":"strong"}]},{"text":" Each alarm's ","type":"text"},{"text":"AlarmActions","type":"text","marks":[{"type":"code_inline"}]},{"text":" is wired to an in-region SNS topic ONLY if the seller supplies ","type":"text"},{"text":"AlertsTopicArn","type":"text","marks":[{"type":"code_inline"}]},{"text":". Because metering errors directly affect BILLING, ","type":"text"},{"text":"\"alarms with no action\" is a NON-RECOMMENDED configuration","type":"text","marks":[{"type":"strong"}]},{"text":": with no ","type":"text"},{"text":"AlertsTopicArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" the revenue-loss alarms fire into the void and the seller is NOT paged when metering STOPS — exactly when revenue is being lost. The skill SHALL guide the seller to create an SNS topic + subscription and set ","type":"text"},{"text":"AlertsTopicArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" as part of the integration (not an optional afterthought), and SHALL flag an integration left without it as incomplete for production. ","type":"text"},{"text":"Secure the topic:","type":"text","marks":[{"type":"strong"}]},{"text":" alarm notifications can carry sensitive identifiers (buyer AWS account IDs, license ARNs, resource names), so the SNS topic SHALL be ENCRYPTED AT REST (SSE — ","type":"text"},{"text":"alias/aws/sns","type":"text","marks":[{"type":"code_inline"}]},{"text":" at minimum, or a customer-managed KMS key) and the seller SHALL verify the subscription recipients (email/HTTPS endpoints) are AUTHORIZED operations personnel only (review subscriptions periodically; consider SNS access/subscription-filter policies). Detail: ","type":"text"},{"text":"references/deployment-steps.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"paragraph","content":[{"text":"\"What alarms tell me metering records are being REJECTED / not metered?\"","type":"text","marks":[{"type":"strong"}]},{"text":" — the stack creates a dedicated alarm on each rejection/failure signal so a seller is paged the moment records stop metering (all fire into ","type":"text"},{"text":"AlertsTopicArn","type":"text","marks":[{"type":"code_inline"}]},{"text":"; default thresholds apply, tunable):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"UsageRecordUnprocessed","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (alarm ","type":"text"},{"text":"*-usage-unprocessed","type":"text","marks":[{"type":"code_inline"}]},{"text":") — ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" returned ","type":"text"},{"text":"UnprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":" that were STILL unprocessed after the submitter's one automatic retry. The submitter does NOT raise on this (it leaves them pending for the next ","type":"text"},{"text":"rate(5m)","type":"text","marks":[{"type":"code_inline"}]},{"text":" cycle), so this metric is the ONLY alarm-able signal for the ","type":"text"},{"text":"UnprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":" case — it does NOT trip the Lambda ","type":"text"},{"text":"Errors","type":"text","marks":[{"type":"code_inline"}]},{"text":" alarm. A persistent breach = records repeatedly failing to meter (throttling, timestamp bounds, downstream issue) → investigate.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"UsageRecordRejected","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (","type":"text"},{"text":"*-usage-rejected","type":"text","marks":[{"type":"code_inline"}]},{"text":") — client-side guardrail rejections before metering (summed across reasons; per-reason breakdown on the dashboard); fix source rows + resubmit. ","type":"text"},{"text":"BatchMeterUsageException","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (","type":"text"},{"text":"*-batchmeterusage-exception","type":"text","marks":[{"type":"code_inline"}]},{"text":") — a REQUEST-level exception failed a whole call (dimension/license/etc.); pipeline bisects to isolate. ","type":"text"},{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (","type":"text"},{"text":"*-customer-not-subscribed","type":"text","marks":[{"type":"code_inline"}]},{"text":") — per-record status, no active subscription (do NOT retry).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Plus age-out alarms (","type":"text"},{"text":"UsageAggregationExpired","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"UsageSubmissionExpired","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":">24h","type":"text","marks":[{"type":"code_inline"}]},{"text":") catch usage that silently never metered, and Lambda ","type":"text"},{"text":"Errors","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"Throttles","type":"text","marks":[{"type":"code_inline"}]},{"text":"/DLQ-depth alarms catch pipeline failures. ","type":"text"},{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a dashboard metric (informational — first submission already billed), not an alarm.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"QUERY PATH — Find, Debug, and Analyze Metering Records","type":"text"}]},{"type":"paragraph","content":[{"text":"Full routing/patterns: ","type":"text"},{"text":"references/query-path.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"references/query-patterns.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Capability boundary (state it; don't return a bounded answer as authoritative).","type":"text","marks":[{"type":"strong"}]},{"text":" This query ","type":"text"},{"text":"USES","type":"text","marks":[{"type":"strong"}]},{"text":" CloudTrail Event History (","type":"text"},{"text":"LookupEvents","type":"text","marks":[{"type":"code_inline"}]},{"text":") in the seller's region, last ≤90 days, ","type":"text"},{"text":"page-bounded and processed in memory","type":"text","marks":[{"type":"strong"}]},{"text":" — a recent, regional, SUBMISSIONS view (includes rejected/duplicate records that were NOT billed) for BASIC operational/debug queries (lookups + simple filters/counts/summaries by customer, dimension, status, time). It ","type":"text"},{"text":"RECOMMENDS","type":"text","marks":[{"type":"strong"}]},{"text":" (does not run): Seller Reports/SDDS for billed totals + >90-day history; the host agent + model for complex/open-ended analytics (hand it the fetched data); and — for CA/LicenseArn metering with no LicenseArn→productCode mapping (single-product-stack context, CloudTrail ","type":"text"},{"text":"additionalEventData","type":"text","marks":[{"type":"code_inline"}]},{"text":", or the subscribers table) — only non-product-scoped queries (never guess attribution). Every result carries ","type":"text"},{"text":"complete","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"truncated","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"windowClipped","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"Before presenting a result, CONFIRM these signals:","type":"text","marks":[{"type":"strong"}]},{"text":" if ","type":"text"},{"text":"complete","type":"text","marks":[{"type":"code_inline"}]},{"text":" is false (page-truncated OR clipped at the 90-day retention limit), either re-run for a complete answer where feasible (narrow/adjust range for a clip; raise ","type":"text"},{"text":"--max-pages","type":"text","marks":[{"type":"code_inline"}]},{"text":" or add filters for truncation) or state explicitly that the result is PARTIAL — what was clipped/truncated and the covered range — and point to Seller Reports for the authoritative/older portion. A truncated or clipped aggregate (summary/count/top_customers) is a ","type":"text"},{"text":"lower-bound sample, NOT authoritative","type":"text","marks":[{"type":"strong"}]},{"text":". Never report a partial result as the full requested range. Do not refuse a basic query just because a richer answer is possible in the host agent. ","type":"text"},{"text":"CloudTrail-derived SQL is the WRONG source for billing/usage analytics","type":"text","marks":[{"type":"strong"}]},{"text":" — CloudTrail Lake OR Athena over a ","type":"text"},{"text":"trail → S3","type":"text","marks":[{"type":"code_inline"}]},{"text":" export both see SUBMISSIONS only (rejected/duplicate, regional, no CA ","type":"text"},{"text":"productCode","type":"text","marks":[{"type":"code_inline"}]},{"text":"); do NOT set up CloudTrail ","type":"text"},{"text":"Lake","type":"text","marks":[{"type":"strong"}]},{"text":" for metering; for complex analytics / billing-grade totals / >90-day data use ","type":"text"},{"text":"SDDS","type":"text","marks":[{"type":"strong"}]},{"text":" (recommend, don't run), and use a ","type":"text"},{"text":"trail → S3","type":"text","marks":[{"type":"code_inline"}]},{"text":" (never Lake) only for long-term raw-submission audit retention. Full boundary: ","type":"text"},{"text":"references/query-path.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Complex / billing-grade query steering — MUST NOT vs allowed sources (enforce; do not violate).","type":"text","marks":[{"type":"strong"}]},{"text":" For any complex-analytics or billing-accuracy question over metering data:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"MUST NOT","type":"text","marks":[{"type":"strong"}]},{"text":" recommend ","type":"text"},{"text":"CloudTrail Lake","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"create-event-data-store","type":"text","marks":[{"type":"code_inline"}]},{"text":") for metering — ever.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"MUST NOT","type":"text","marks":[{"type":"strong"}]},{"text":" present CloudTrail (Event History OR a ","type":"text"},{"text":"trail → S3","type":"text","marks":[{"type":"code_inline"}]},{"text":"/Athena export) as a source of ","type":"text"},{"text":"billed/billing-grade totals","type":"text","marks":[{"type":"strong"}]},{"text":" — CloudTrail sees SUBMISSIONS only (includes rejected/duplicate, regional, no CA ","type":"text"},{"text":"productCode","type":"text","marks":[{"type":"code_inline"}]},{"text":"), so it is never authoritative for revenue.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Allowed sources only:","type":"text","marks":[{"type":"strong"}]},{"text":" for billed/disbursed totals and >90-day billed history → ","type":"text"},{"text":"Seller Reports / AMMP Billed Revenue dashboard","type":"text","marks":[{"type":"strong"}]},{"text":" and ","type":"text"},{"text":"SDDS","type":"text","marks":[{"type":"strong"}]},{"text":" (recommend, do not run); for recent (≤90-day) regional SUBMISSION debugging → ","type":"text"},{"text":"CloudTrail Event History","type":"text","marks":[{"type":"strong"}]},{"text":"; for complex/open-ended analytics → hand the fetched data to the ","type":"text"},{"text":"host agent + model","type":"text","marks":[{"type":"strong"}]},{"text":"; for long-term RAW-submission audit retention only → a plain ","type":"text"},{"text":"trail → S3","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (NOT Lake) + Athena, understanding it is submissions-not-billing. Route every complex-billing ask to these allowed sources and name why CloudTrail is not billing-authoritative.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Steps","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Ask the metering region","type":"text","marks":[{"type":"strong"}]},{"text":" (MUST — CloudTrail is regional).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Extract filters: ","type":"text"},{"text":"customer_id","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"status","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dimension","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"start_date","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"end_date","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"query_type","type":"text","marks":[{"type":"code_inline"}]},{"text":" (summary/detail/count/list_failures/top_customers), ","type":"text"},{"text":"top_n","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run the script (default: last 7 days):","type":"text"}]}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 ./scripts/query_metering.py \\\n --region \u003cSELLER_METERING_REGION> \\\n [--days N] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] \\\n [--customer-id ACCT_ID] [--status STATUS] [--dimension DIM] \\\n [--product-code PC] [--deployment-product-code PC] \\\n [--query-type summary|detail|count|list_failures|top_customers] \\\n [--top-n N] [--limit N] [--max-pages N]","type":"text"}]},{"type":"paragraph","content":[{"text":"CloudTrail Event History recipe (what the script does; use directly if querying by hand).","type":"text","marks":[{"type":"strong"}]},{"text":" Event History is on by DEFAULT — no data store, no setup, ","type":"text"},{"text":"never CloudTrail Lake / ","type":"text","marks":[{"type":"strong"}]},{"text":"create-event-data-store","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" for metering. Query IN the seller's metering region and filter on the metering event source:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"aws cloudtrail lookup-events --region \u003cSELLER_METERING_REGION> \\\n --lookup-attributes AttributeKey=EventSource,AttributeValue=metering-marketplace.amazonaws.com \\\n --start-time \u003cISO> --end-time \u003cISO>","type":"text"}]},{"type":"paragraph","content":[{"text":"Each ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" event's CloudTrail ","type":"text"},{"text":"responseElements","type":"text","marks":[{"type":"code_inline"}]},{"text":" carries ","type":"text"},{"text":"results","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (per-record ","type":"text"},{"text":"status","type":"text","marks":[{"type":"code_inline"}]},{"text":": ","type":"text"},{"text":"Success","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"}]},{"text":" — those are the ONLY per-record statuses) and ","type":"text"},{"text":"unprocessedRecords","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — to verify a submission, check ","type":"text"},{"text":"results","type":"text","marks":[{"type":"code_inline"}]},{"text":" vs ","type":"text"},{"text":"unprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":" FIRST (records in ","type":"text"},{"text":"unprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":" were not processed — see the ","type":"text"},{"text":"UnprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":" note below). CloudTrail lowercases the first letter of response fields, so read ","type":"text"},{"text":"results","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"status","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"meteringRecordId","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"unprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":" (lowercase) — the direct SDK ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" response uses PascalCase (","type":"text"},{"text":"Results","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"Status","type":"text","marks":[{"type":"code_inline"}]},{"text":"/ ","type":"text"},{"text":"MeteringRecordId","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"UnprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":"). An undefined dimension is NOT a per-record status — it fails the whole call with a request-level ","type":"text"},{"text":"InvalidUsageDimensionException","type":"text","marks":[{"type":"code_inline"}]},{"text":" (see below). ≤90 days only; for older/billed totals use Seller Reports/SDDS (recommended, not queried). ","type":"text"},{"text":"CloudTrail is NOT revenue","type":"text","marks":[{"type":"strong"}]},{"text":" — it shows submissions (incl. rejected/duplicate, not billed). Metered usage ","type":"text"},{"text":"bills at the START of the following month (the 2nd/3rd)","type":"text","marks":[{"type":"strong"}]},{"text":" — e.g. November usage lands on the December invoice — and dashboard revenue is ","type":"text"},{"text":"estimated","type":"text","marks":[{"type":"strong"}]},{"text":" until the month closes; use Seller Reports for billed/disbursed amounts.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Status meanings","type":"text"}]},{"type":"paragraph","content":[{"text":"Per-record ","type":"text"},{"text":"Results[].Status","type":"text","marks":[{"type":"code_inline"}]},{"text":" values:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Status","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Meaning","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Action","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Success","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Accepted and billed","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"None","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"No active subscription","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"⚠️ Do NOT retry","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"First-write-wins and reported; a differing-quantity resubmit is NOT billed","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Safe only for an identical retry; never to change a quantity (under-billing risk)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Request-level exceptions (fail the ENTIRE ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" call, NOT a per-record status). Each has a ","type":"text"},{"text":"client-side guardrail reason code","type":"text","marks":[{"type":"strong"}]},{"text":" the pipeline stamps when it rejects the row BEFORE the API call (","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":", not billed), plus the ","type":"text"},{"text":"server-side exception","type":"text","marks":[{"type":"strong"}]},{"text":" if a row reaches the API — surface BOTH when explaining a rejection:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Server-side exception","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Client-side reason code","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Meaning","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Action","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidUsageDimensionException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"— (no client-side check — dimension validity is server-authoritative)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A dimension not defined in the catalog (server-authoritative; case-sensitive; not billed)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Define/fix the dimension in the AMMP catalog (case-sensitive) — do NOT change the usage-row writer; the row is well-formed.","type":"text","marks":[{"type":"strong"}]},{"text":" The pipeline does NOT validate dimension names client-side (it is not configured with the catalog's dimension list), so there is no client-side reason code for this — it is caught ONLY at the API. The pipeline isolates the offending record by bisection (depth-bounded, ⌈log2(batch size)⌉=5) so its co-batched records still meter, marks the aggregated record ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"}]},{"text":" (SERVER-SIDE submit failure, NOT ","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":" — which never appears on the aggregated table; exception name in ","type":"text"},{"text":"meteringStatusReason","type":"text","marks":[{"type":"code_inline"}]},{"text":"), and emits the ","type":"text"},{"text":"BatchMeterUsageException","type":"text","marks":[{"type":"code_inline"}]},{"text":" metric/alarm that surfaces it.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TimestampOutOfBoundsException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MalformedTimestamp","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A record older than 24h (e.g. a 30h-old row) or otherwise outside the window — REJECTED, not submitted","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fix the timestamp; the client-side guardrail rejects the row with reason ","type":"text"},{"text":"MalformedTimestamp","type":"text","marks":[{"type":"code_inline"}]},{"text":" before batching, and one bad timestamp that reaches the API rejects the whole batch until isolated.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidLicenseException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidLicenseArn","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A malformed/invalid ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Shape-validate client-side (reason ","type":"text"},{"text":"InvalidLicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":"); a server-side ","type":"text"},{"text":"InvalidLicenseException","type":"text","marks":[{"type":"code_inline"}]},{"text":" is isolated by (bounded) bisection and marked ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the aggregated record (NOT ","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":"), with the exception name in ","type":"text"},{"text":"meteringStatusReason","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidTagException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TooManyTags","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"More than 5 ","type":"text"},{"text":"usageAllocations","type":"text","marks":[{"type":"code_inline"}]},{"text":" tags on a UsageRecord (also other invalid tag key/value shapes)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reduce to ≤5 tags (and ensure non-empty Key/Value); the client-side guardrail rejects the row with reason ","type":"text"},{"text":"TooManyTags","type":"text","marks":[{"type":"code_inline"}]},{"text":" before batching. The server-side request-level exception is ","type":"text"},{"text":"InvalidTagException","type":"text","marks":[{"type":"code_inline"}]},{"text":"; if it reaches the API it is isolated by (bounded) bisection and the aggregated record is marked ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidUsageAllocationsException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AllocationSumMismatch","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"TooManyAllocations","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"MixedAllocatedAndUnallocated","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Malformed ","type":"text"},{"text":"usageAllocations","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the merged record — Σ ","type":"text"},{"text":"AllocatedUsageQuantity","type":"text","marks":[{"type":"code_inline"}]},{"text":" ≠ record ","type":"text"},{"text":"Quantity","type":"text","marks":[{"type":"code_inline"}]},{"text":", >2500 merged tag-sets, or a tagged/untagged mix","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The aggregator validates allocations client-side and rejects with the specific reason code (","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":") before batching. If such a record reaches the API, the request-level ","type":"text"},{"text":"InvalidUsageAllocationsException","type":"text","marks":[{"type":"code_inline"}]},{"text":" is isolated by (bounded) bisection and the aggregated record is marked ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"}]},{"text":" (exception name in ","type":"text"},{"text":"meteringStatusReason","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidProductCodeException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"— (no client-side pre-check)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The product code on the record is not a valid/owned product code for the calling (publisher) account","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A SERVER-SIDE request-level exception — there is no client-side guardrail reason code for it (the pipeline is deployed per product and does not pre-validate product-code ownership). Isolated by (bounded) bisection and the aggregated record is marked ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"}]},{"text":" (exception name in ","type":"text"},{"text":"meteringStatusReason","type":"text","marks":[{"type":"code_inline"}]},{"text":"); it is NOT ","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":". Fix the product code / call from the publishing account.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidCustomerIdentifierException","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"legacy","type":"text","marks":[{"type":"strong"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" that is not valid for the product. ","type":"text"},{"text":"Legacy (pre-CA) path only","type":"text","marks":[{"type":"strong"}]},{"text":" — Concurrent Agreements products identify by ","type":"text"},{"text":"CustomerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" and do NOT use ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":", so a CA integration should never see this. Named here for completeness and for existing legacy sellers.","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Legacy ","type":"text"},{"text":"ProductCode","type":"text","marks":[{"type":"code_inline"}]},{"text":"+","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" sellers: re-resolve the identifier (","type":"text"},{"text":"ResolveCustomer","type":"text","marks":[{"type":"code_inline"}]},{"text":") / fix the stored ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":". For a NEW/CA product this is a sign something is passing a (deprecated, null-for-CA) ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" — use ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ValidationException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The request shape is INVALID — most relevantly, ","type":"text"},{"text":"mixing identity models in one ","type":"text","marks":[{"type":"strong"}]},{"text":"UsageRecord","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":": passing ","type":"text","marks":[{"type":"strong"}]},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" together with ","type":"text","marks":[{"type":"strong"}]},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":".","type":"text","marks":[{"type":"strong"}]},{"text":" CA does NOT support ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":"-based metering, so a record carrying BOTH is a malformed request and ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" rejects the whole call with ","type":"text"},{"text":"ValidationException","type":"text","marks":[{"type":"code_inline"}]},{"text":" (also fires for other request-schema violations).","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Send ONLY the CA identity — ","type":"text"},{"text":"CustomerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" (NO ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":") — for new/CA products; send ONLY the legacy ","type":"text"},{"text":"ProductCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" for legacy products. Never combine ","type":"text"},{"text":"CustomerIdentifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the same record.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DisabledApiException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"WHOLE-REGION availability failure","type":"text","marks":[{"type":"strong"}]},{"text":", not a per-record validation reject: ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" is not offered in the region the call was made in (the entire call fails).","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Meter in a SUPPORTED region (the seller chooses the metering region; deploy a stack per metering region). The pipeline treats it like a ","type":"text"},{"text":"transient","type":"text","marks":[{"type":"strong"}]},{"text":" whole-call failure: it is NOT isolated by bisection and NOT stamped ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"}]},{"text":" on any record (there is no poison record to isolate) — the submitter re-raises, ","type":"text"},{"text":"meteringPending","type":"text","marks":[{"type":"code_inline"}]},{"text":" is KEPT, and the records are retried on the next scheduled run (the Lambda ","type":"text"},{"text":"Errors","type":"text","marks":[{"type":"code_inline"}]},{"text":" alarm fires) until the region is fixed or they age out at 24h. It is NOT ","type":"text"},{"text":"InvalidEndpointRegionException","type":"text","marks":[{"type":"code_inline"}]},{"text":" (that is AMI/Container ","type":"text"},{"text":"MeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" only).","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Sort-key / reject-code ordering:","type":"text","marks":[{"type":"strong"}]},{"text":" the pipeline validates a row's shape BEFORE it can enter a batch. A row whose sort key (","type":"text"},{"text":"customerAWSAccountId#dimension#timestamp","type":"text","marks":[{"type":"code_inline"}]},{"text":") carries a malformed or out-of-window timestamp is ","type":"text"},{"text":"rejected client-side with reason code ","type":"text","marks":[{"type":"strong"}]},{"text":"MalformedTimestamp","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (marked ","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":", emits ","type":"text"},{"text":"UsageRecordRejected","type":"text","marks":[{"type":"code_inline"}]},{"text":") — it never reaches ","type":"text"},{"text":"BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":", so it does NOT surface as a server-side ","type":"text"},{"text":"TimestampOutOfBoundsException","type":"text","marks":[{"type":"code_inline"}]},{"text":" unless a bad timestamp slips through into a batch. Always name the client-side ","type":"text"},{"text":"MalformedTimestamp","type":"text","marks":[{"type":"code_inline"}]},{"text":" reason code for a malformed-timestamp sort key, and its server-side ","type":"text"},{"text":"TimestampOutOfBoundsException","type":"text","marks":[{"type":"code_inline"}]},{"text":" equivalent.","type":"text"}]},{"type":"paragraph","content":[{"text":"Full reject-/status-code catalog (the ONE place to cite for any reason-code question).","type":"text","marks":[{"type":"strong"}]},{"text":" A question like \"what does ","type":"text"},{"text":"MixedAllocatedAndUnallocated","type":"text","marks":[{"type":"code_inline"}]},{"text":" mean?\" should be answered from this complete set, mapping the client-side guardrail reason code to its server-side equivalent (where one exists):","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Client-side reason code (","type":"text"},{"text":"RejectedClientSide","type":"text","marks":[{"type":"code_inline"}]},{"text":", pre-API, not billed)","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Server-side equivalent","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Meaning","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MalformedSortKey","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"SortKeyMismatch","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"— (never reaches API)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Sort key not ","type":"text"},{"text":"customerAWSAccountId#dimension#timestamp","type":"text","marks":[{"type":"code_inline"}]},{"text":", or its segments don't match the row's own attributes","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MalformedTimestamp","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"TimestampOutOfWindow","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TimestampOutOfBoundsException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Non-whole-second, unparseable, or >24h-old timestamp","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MissingDimension","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Row has no ","type":"text"},{"text":"dimension","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"NegativeQuantity","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Quantity \u003c 0","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"NonIntegerQuantity","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Quantity not a whole integer","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TooManyTags","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidTagException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":">5 ","type":"text"},{"text":"usageAllocations","type":"text","marks":[{"type":"code_inline"}]},{"text":" tags on a record (the server-side request-level exception is ","type":"text"},{"text":"InvalidTagException","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"TooManyTags","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the client-side reason code)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"TooManyAllocations","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidUsageAllocationsException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":">2500 merged tag-sets in a group","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AllocationSumMismatch","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidUsageAllocationsException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AllocatedUsageQuantity","type":"text","marks":[{"type":"code_inline"}]},{"text":" values don't sum to the row ","type":"text"},{"text":"quantity","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MixedAllocatedAndUnallocated","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidUsageAllocationsException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Group mixes tagged and untagged non-zero rows","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidLicenseArn","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InvalidLicenseException","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Malformed/invalid ","type":"text"},{"text":"LicenseArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" (also fires when products are mixed in one batch)","type":"text"}]}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Dimension-NAME validity is NOT a client-side reason code.","type":"text","marks":[{"type":"strong"}]},{"text":" The pipeline only checks that a ","type":"text"},{"text":"dimension","type":"text","marks":[{"type":"code_inline"}]},{"text":" is PRESENT (","type":"text"},{"text":"MissingDimension","type":"text","marks":[{"type":"code_inline"}]},{"text":"); it does NOT validate the dimension NAME against the catalog (it is deliberately not configured with the product's dimension list, so adding/renaming a dimension needs no redeploy). An undefined/misspelled dimension is caught ONLY server-side as the request-level ","type":"text"},{"text":"InvalidUsageDimensionException","type":"text","marks":[{"type":"code_inline"}]},{"text":" → the aggregated record is stamped ","type":"text"},{"text":"Error","type":"text","marks":[{"type":"code_inline"}]},{"text":" (see the request-level table above). There is no ","type":"text"},{"text":"InvalidDimension","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"UnknownDimension","type":"text","marks":[{"type":"code_inline"}]},{"text":" client-side code.","type":"text"}]}]},{"type":"paragraph","content":[{"text":"Plus the terminal per-record ","type":"text"},{"text":"Results[].Status","type":"text","marks":[{"type":"code_inline"}]},{"text":" verdicts returned BY the API (not client-side reasons): ","type":"text"},{"text":"Success","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (billed), ","type":"text"},{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (first-write-wins; a differing-quantity resubmit is NOT billed — under-billing risk, not \"benign\"), ","type":"text"},{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (no active subscription — do NOT retry). And ","type":"text"},{"text":"UnprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":" (transient, retryable) — see below.","type":"text"}]},{"type":"paragraph","content":[{"text":"UnprocessedRecords","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (a THIRD, distinct outcome — neither a per-record ","type":"text","marks":[{"type":"strong"}]},{"text":"Results[].Status","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" verdict nor a request-level exception):","type":"text","marks":[{"type":"strong"}]},{"text":" a PARTIAL-BATCH result (succeeded records in ","type":"text"},{"text":"results","type":"text","marks":[{"type":"code_inline"}]},{"text":"; failed-to-process ones in ","type":"text"},{"text":"unprocessedRecords","type":"text","marks":[{"type":"code_inline"}]},{"text":" — NOT billed, typically TRANSIENT ","type":"text"},{"text":"ThrottlingException","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"InternalServiceException","type":"text","marks":[{"type":"code_inline"}]},{"text":"), SAFE to re-submit (idempotent); the submitter retries ONCE then leaves it for the NEXT scheduled run (alarmed via ","type":"text"},{"text":"UsageRecordUnprocessed","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Do NOT treat it as accepted/billed or confuse it with the FINAL ","type":"text"},{"text":"Results[].Status","type":"text","marks":[{"type":"code_inline"}]},{"text":" verdicts above.","type":"text"}]},{"type":"paragraph","content":[{"text":"Data tiers:","type":"text","marks":[{"type":"strong"}]},{"text":" CloudTrail Event History (≤90 days, free, real-time) is the tier the skill queries directly; it shows ALL submissions (incl. rejected/duplicate), Seller Reports show only billed — so \"totals don't match\" = the rejected/duplicate delta. Two separate history questions, don't conflate them:","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Reconciling SUBMITTED (CloudTrail) vs BILLED (Seller Reports).","type":"text","marks":[{"type":"strong"}]},{"text":" Treat ","type":"text"},{"text":"Seller Reports / AMMP Billed Revenue as the billed source of truth","type":"text","marks":[{"type":"strong"}]},{"text":"; CloudTrail is submissions only. To reconcile, ","type":"text"},{"text":"filter CloudTrail to ","type":"text","marks":[{"type":"strong"}]},{"text":"Status=Success","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (exclude ","type":"text"},{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"CustomerNotSubscribed","type":"text","marks":[{"type":"code_inline"}]},{"text":", and request-level exceptions — none of those bill) and compare that to the billed report; the leftover gap is the expected rejected/duplicate delta. ","type":"text"},{"text":"Watch for ","type":"text","marks":[{"type":"strong"}]},{"text":"DuplicateRecord","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"s with DIFFERENT quantities","type":"text","marks":[{"type":"strong"}]},{"text":" for the same ","type":"text"},{"text":"(account, licenseArn, dimension, hour)","type":"text","marks":[{"type":"code_inline"}]},{"text":" — that is NOT benign: first-write-wins means the later (often larger) quantity was NOT billed, so it can indicate ","type":"text"},{"text":"under-reporting/under-billing","type":"text","marks":[{"type":"strong"}]},{"text":". Surface those as a reconciliation red flag rather than dismissing duplicates.","type":"text"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"BILLED history (any range) needs NO setup","type":"text","marks":[{"type":"strong"}]},{"text":" — the AMMP Insights ","type":"text"},{"text":"\"Billed Revenue\" dashboard","type":"text","marks":[{"type":"strong"}]},{"text":" (only ","type":"text"},{"text":"aws-marketplace:GetSellerDashboard","type":"text","marks":[{"type":"code_inline"}]},{"text":"; default 6 months, widen the invoice-date filter). Console-only (QuickSight) + manual CSV export — ","type":"text"},{"text":"no on-demand billed API","type":"text","marks":[{"type":"strong"}]},{"text":". Programmatic billed data = ","type":"text"},{"text":"SDDS","type":"text","marks":[{"type":"strong"}]},{"text":" (daily S3 push + your Athena/ETL), but push-only and ","type":"text"},{"text":"enrollment-forward (no backfill)","type":"text","marks":[{"type":"strong"}]},{"text":". Skill recommends, doesn't run; ","type":"text"},{"text":"never ","type":"text","marks":[{"type":"strong"}]},{"text":"GenerateDataSet","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":". Detail: ","type":"text"},{"text":"references/seller-reports.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Discounts / private offers are NOT a metering or Seller-Reports concern — redirect to the public AWS Marketplace channel (AMMP).","type":"text","marks":[{"type":"strong"}]},{"text":" If a seller asks why billed amounts differ from metered usage because of a ","type":"text"},{"text":"discount or private offer","type":"text","marks":[{"type":"strong"}]},{"text":", or how to apply/change one, do NOT try to explain or reconcile it from metering data and do NOT stop at \"out of metering scope.\" The pricing that turns metered quantity into billed revenue (public price, private offers, discounts, EULA terms) is managed in the ","type":"text"},{"text":"AWS Marketplace Management Portal (AMMP)","type":"text","marks":[{"type":"strong"}]},{"text":" — state that the discount/private-offer delta is expected (Seller Reports show BILLED revenue after any negotiated discount, while CloudTrail shows raw metered SUBMISSIONS) and redirect discount/private-offer creation and management to AMMP → ","type":"text"},{"text":"AWS Marketplace Management Portal","type":"text","marks":[{"type":"link","attrs":{"href":"https://aws.amazon.com/marketplace/management/","title":null}}]},{"text":" / ","type":"text"},{"text":"private offers","type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.aws.amazon.com/marketplace/latest/userguide/private-offers-overview.html","title":null}}]},{"text":".","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"RAW SUBMISSION history beyond 90 days has NO out-of-the-box source","type":"text","marks":[{"type":"strong"}]},{"text":" (Seller Reports/SDDS are billed-only; Event History keeps 90 days). Retain via a self-managed ","type":"text"},{"text":"CloudTrail trail → S3","type":"text","marks":[{"type":"strong"}]},{"text":" on ","type":"text"},{"text":"metering-marketplace.amazonaws.com","type":"text","marks":[{"type":"code_inline"}]},{"text":" (plain trail, ","type":"text"},{"text":"NOT Lake","type":"text","marks":[{"type":"strong"}]},{"text":"; Athena) — also ","type":"text"},{"text":"from setup forward, no backfill","type":"text","marks":[{"type":"strong"}]},{"text":"; complementary DynamoDB PITR export of ","type":"text"},{"text":"aggregated_usage","type":"text","marks":[{"type":"code_inline"}]},{"text":". Detail: ","type":"text"},{"text":"references/query-patterns.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Query references","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Routing + parameters: ","type":"text"},{"text":"references/query-path.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" · CloudTrail CLI patterns + pagination: ","type":"text"},{"text":"references/query-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Seller Reports reconciliation + SDDS automation boundary: ","type":"text"},{"text":"references/seller-reports.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" · Dimension reference: ","type":"text"},{"text":"references/dimension-reference.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Key APIs","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"API","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Region","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Purpose","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ResolveCustomer","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"any region (seller's choice)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Resolve token → LicenseArn + customerAWSAccountId + productCode at registration; regional, from the publishing account; independent of the BatchMeterUsage region","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BatchMeterUsage","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"seller's metering region","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Submit usage records (hourly)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DescribeEntity","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"us-east-1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Validate product / type / visibility","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"LookupEvents","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"seller's region","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Query metering records (≤90 days)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"IAM action names: ","type":"text"},{"text":"aws-marketplace:BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" (meter), ","type":"text"},{"text":"aws-marketplace:ResolveCustomer","type":"text","marks":[{"type":"code_inline"}]},{"text":" (register), ","type":"text"},{"text":"aws-marketplace:GetSellerDashboard","type":"text","marks":[{"type":"code_inline"}]},{"text":" (reports). Namespace is ","type":"text"},{"text":"aws-marketplace:","type":"text","marks":[{"type":"code_inline"}]},{"text":", NOT ","type":"text"},{"text":"meteringmarketplace:*","type":"text","marks":[{"type":"code_inline"}]},{"text":". When diagnosing AccessDenied, name the exact action and check the execution role, condition keys, and Resource-ARN mismatches.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Security","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Least privilege:","type":"text","marks":[{"type":"strong"}]},{"text":" execution roles grant ONLY the actions used — meter path: ","type":"text"},{"text":"aws-marketplace:BatchMeterUsage","type":"text","marks":[{"type":"code_inline"}]},{"text":" + scoped DynamoDB ","type":"text"},{"text":"Query","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"UpdateItem","type":"text","marks":[{"type":"code_inline"}]},{"text":" on its tables + the ","type":"text"},{"text":"metering_pending","type":"text","marks":[{"type":"code_inline"}]},{"text":" index; register: ","type":"text"},{"text":"aws-marketplace:ResolveCustomer","type":"text","marks":[{"type":"code_inline"}]},{"text":" + scoped ","type":"text"},{"text":"Query","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the ","type":"text"},{"text":"customerAWSAccountId","type":"text","marks":[{"type":"code_inline"}]},{"text":" GSI + item ops. ","type":"text"},{"text":"No ","type":"text","marks":[{"type":"strong"}]},{"text":"dynamodb:Scan","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (lookups use GSI ","type":"text"},{"text":"Query","type":"text","marks":[{"type":"code_inline"}]},{"text":"); never ","type":"text"},{"text":"dynamodb:*","type":"text","marks":[{"type":"code_inline"}]},{"text":" or broad ","type":"text"},{"text":"aws-marketplace:*","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No swallowed auth errors:","type":"text","marks":[{"type":"strong"}]},{"text":" subscriber/license lookups do NOT catch ","type":"text"},{"text":"AccessDeniedException","type":"text","marks":[{"type":"code_inline"}]},{"text":" as \"not found\" — permission/config errors surface (Lambda error / 500 / DLQ).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Encryption at rest:","type":"text","marks":[{"type":"strong"}]},{"text":" SQS (","type":"text"},{"text":"SqsManagedSseEnabled","type":"text","marks":[{"type":"code_inline"}]},{"text":"), SNS (","type":"text"},{"text":"alias/aws/sns","type":"text","marks":[{"type":"code_inline"}]},{"text":"), DynamoDB (","type":"text"},{"text":"SSEEnabled","type":"text","marks":[{"type":"code_inline"}]},{"text":"); Lambda + API log groups are explicit resources with ","type":"text"},{"text":"RetentionInDays","type":"text","marks":[{"type":"code_inline"}]},{"text":" + optional ","type":"text"},{"text":"KmsKeyId","type":"text","marks":[{"type":"code_inline"}]},{"text":" (they contain buyer account IDs / license ARNs).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Public registration API:","type":"text","marks":[{"type":"strong"}]},{"text":" intentionally open (Marketplace POSTs the token unauthenticated) → template attaches a baseline WAF WebACL by default (rate-based + common-exploit; ","type":"text"},{"text":"WebAclArn","type":"text","marks":[{"type":"code_inline"}]},{"text":" overrides); API Gateway throttling + access logging always on; register Lambda returns security headers and persists ONLY an allowlist of length-bounded fields (","type":"text"},{"text":"AllowedRegistrationFields","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Credentials (ephemeral-first):","type":"text","marks":[{"type":"strong"}]},{"text":" SSO → ","type":"text"},{"text":"sts assume-role","type":"text","marks":[{"type":"code_inline"}]},{"text":"; no long-lived keys, don't ask for keys first. Deployer role uses a conditioned trust policy (not open ","type":"text"},{"text":":root","type":"text","marks":[{"type":"code_inline"}]},{"text":") + permissions-boundary-bounded ","type":"text"},{"text":"iam:CreateRole","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stage name:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"StageName","type":"text","marks":[{"type":"code_inline"}]},{"text":" has no default + an ","type":"text"},{"text":"AllowedPattern","type":"text","marks":[{"type":"code_inline"}]},{"text":" rejecting empty; mandatory questionnaire answer; ","type":"text"},{"text":"sam deploy","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"deploy.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" fail fast (no ","type":"text"},{"text":"prod","type":"text","marks":[{"type":"code_inline"}]},{"text":" fallback).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Event scoping:","type":"text","marks":[{"type":"strong"}]},{"text":" EventBridge rule scoped to ","type":"text"},{"text":"source: [\"aws.agreement-marketplace\"]","type":"text","marks":[{"type":"code_inline"}]},{"text":"; the EventBridge→SQS policy via ","type":"text"},{"text":"aws:SourceArn","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Data classification:","type":"text","marks":[{"type":"strong"}]},{"text":" customer AWS account IDs + agreement metadata are sensitive — avoid logging full identifiers; encrypt log groups that contain them.","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"aws-marketplace-metering","author":"@skillopedia","source":{"stars":3376,"repo_name":"agent-toolkit-for-aws","origin_url":"https://github.com/aws/agent-toolkit-for-aws/blob/HEAD/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/SKILL.md","repo_owner":"aws","body_sha256":"00a1a82e8e377fe2ddf460bd33cee45aba3261f41e6aae17aa92afdf3d0a355f","cluster_key":"0a7fb147411a50f8689a3a26882ef652d5e064d07e2b812efba0c744fcb82f14","clean_bundle":{"format":"clean-skill-bundle-v1","source":"aws/agent-toolkit-for-aws/skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/SKILL.md","text_bytes":544914,"source_commit":"1761e96813ebcbd43ed81d19e837219033f0afe5","skills_sh_rank":11441,"attachment_count":30,"text_attachments":30,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/specialized-skills/aws-marketplace-skills/aws-marketplace-metering/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"devops-infrastructure","category_label":"DevOps"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"devops-infrastructure","import_tag":"skills-sh-top20k-2026-11","description":"Deploys, queries, and debugs AWS Marketplace usage-based (PAYG) metering — the pipeline (ResolveCustomer, BatchMeterUsage, EventBridge via SAM) and querying/debugging metering records, statuses, failures, discrepancies. Covers Concurrent Agreements (CustomerAWSAccountId + LicenseArn), multi-product, CloudTrail Event History (90 days; never CloudTrail Lake), Seller Reports (Billed Revenue/Usage columns + CSV/SDDS parsing), existing-stack detection, metering region for BatchMeterUsage/MeterUsage (DisabledApiException), metered AMI/Container + IAM roles, where registration data lives (customer-profile table), legacy ProductCode + CustomerIdentifier, DuplicateRecord/CustomerNotSubscribed, bad LicenseArn. Also SCOPES adjacent asks — flat-rate/SaaS-Subscription \"do I still meter\", CCP included-units + overage, contract term/annual renewal, forecasting future bills/usage — with the right answer or a one-line redirect. Use for any BatchMeterUsage/ResolveCustomer or SaaS usage-billing question."}},"renderedAt":1793687067492}

AWS Marketplace Metering Overview Complete lifecycle management for AWS Marketplace usage-based metering: - Setup (SaaS PAYG) — deploy a new serverless metering integration (SAM, ResolveCustomer, BatchMeterUsage, EventBridge). - Query — find, inspect, debug, analyze existing metering submissions (CloudTrail Event History ≤90 days; Seller Reports for billed history). Scope is SaaS Usage-based (PAYG). SaaS Contract, Contract with Consumption Pricing (CCP), and AMI/Container are out of scope — redirect to public AWS docs. Recommended: the AWS MCP server provides sandboxed execution and audit log…