Skip to content

Part 7 of 7 · Parking permit issuer series ~7 min read

Engineering reference: the parking permit issuer architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the allocation record, and the usage aggregation.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The parking permit issuer drawn with AWS service namesThree boxes across the top outside the AWS account. Applications, submitted through a form annually. A Usage signal from a barrier, a spot check, or simply asking. And Permit holders and the waiting list. Inside the account, three groups. An API for applications and EventBridge running the quarterly review. Three Lambda functions named allocate, usage and review. And two DynamoDB tables named permits and applications. A note gives the region as us-east-1, one account, and states that usage is stored as counts per quarter with individual events discarded.AWS ACCOUNTApplicationsa form, annuallyUsage signalbarrier, spot check,or just askingPermit holdersand the waiting listAPI + EventBridgeapplications,quarterly reviewLambda x3allocate, usage, reviewDynamoDB x2permits, applicationsingroundsoutus-east-1. One account. Usage stored as counts per quarter; individual events discarded.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Database
  • Networking
  • Front-end & mobile
  • People

Region and account

  • Region: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
pp-allocateAPI, once per allocation roundApplies the published rule, records the reason per decision, builds the ordered waiting list120s / 1024 MB
pp-usageAPI or scheduled importIncrements a per-permit per-quarter counter; never stores the individual event10s / 512 MB
pp-reviewEventBridge, quarterlyFinds low-usage permits, sends the first message, tracks replies through the reclaim ladder60s / 512 MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
pp-allocate-roledynamodb:Query, dynamodb:PutItem, ses:SendEmailBoth tables; one verified identity
pp-usage-roledynamodb:UpdateItemPermits only, and only the counter attribute
pp-review-roledynamodb:Query, dynamodb:UpdateItem, ses:SendEmailPermits; one verified identity

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: permits

PK   permit_id         S
     person_id         S
     vehicles          L   registrations; people change cars
     issued_at         S
     expires_at        S   annual, or the temporary end date
     kind              S   standard | temporary_need | accessible
     allocated_by_rule S   ’distance, 6.2km, above the 4.8km threshold’
     usage             M   {2026Q3: 41, 2026Q4: 3} — counts only
     review_state      S   none | asked | reason_given | releasing
     review_reason     S   the reason they gave, verbatim

`usage` holds counts, never dates. Storing which days somebody drove in
creates an attendance record collected for a different purpose.

Table: applications

PK   round_id          S   2026
SK   person_id         S
     applied_at        S
     distance_km       N   or whatever the published rule uses
     declared_need     S   free text, assessed by a person
     outcome           S   allocated | waiting | declined
     rule_version      S   the exact rule text published for this round
     tie_break_draw    N   recorded, because ties must be explicable
     list_position     N   shown to the applicant, and it moves

`rule_version` stores the published text, so a decision can always be
checked against the rule that was actually in force.

Inbound and outbound

  • Applications open for a stated window after the rule is published, and the rule text is stored with the round rather than referenced.
  • Usage arrives as increments, from whatever signal exists. The API accepts a permit and a date and stores only a counter.
  • Accessible and temporary permits are a different kind and are excluded from the ratio, the ballot and the reclaim review.
  • Waiting list position is computed from the stored ordering and shown to each applicant, including when it moves.

The model call

  • There is no model in this system. Allocation is sorting and the reclaim ladder is four states.
  • The tempting use is assessing declared need from free text. That is a judgement about somebody’s circumstances and it belongs to a person who can ask a follow-up question.
  • The wrong use is number plate recognition and any inference from it. Part 5 covers what that record becomes.
  • A defensible use is normalising vehicle registrations typed in different formats, which is a small annoyance solved more cheaply by a regular expression.
  • The cost page assumes none, which is why messaging is the only variable.

Things worth knowing before you build it

  • Store the published rule text with the allocation round. A decision that cannot be checked against the rule in force at the time is not defensible.
  • Store usage as counts, never as dated events. The dated version is an attendance record and somebody will eventually ask to use it that way.
  • Show waiting list position and its movement. An invisible list is universally assumed not to move.
  • Keep accessible and temporary permits outside the ratio and the review. Including them produces both wrong arithmetic and an unpleasant conversation.
  • Put a person in front of every enforcement action beyond a polite note. One automatic penalty issued to somebody at a hospital appointment outweighs the whole regime.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts