Brainstorm
Paylocity reporting sync brainstorm
This is the cleanest example of pre-plan thinking in the repo. It compares a standalone repo, a monorepo MVP, and a more ambitious near-real-time first pass before implementation begins.
Real artifact appendix
The deck is the compressed version. This page is the longer cut: the brainstorm that framed the work, the plan that sequenced it, and the solution doc that captured a hard-earned infrastructure lesson. If the audience wants more than slide abstractions, this is the place to open.
Use it live
Jump to section
Scroll continuously or hop straight to the artifact you want to walk through.
Brainstorm
This is the cleanest example of pre-plan thinking in the repo. It compares a standalone repo, a monorepo MVP, and a more ambitious near-real-time first pass before implementation begins.
Plan
This is the strongest full plan artifact in the repo right now. It turns a reporting ask into a real delivery contract: repo placement, runtime choice, Terraform boundaries, migration ownership, and phased rollout.
Solution
This one is useful because it captures a real infrastructure lesson with a clean permanent fix. The deploy contract was asking Terraform to look up Entra data it did not actually need.
An internal reporting ingestion foundation that pulls Paylocity time-entry data into a Monti-owned Azure SQL schema, with enough auditability to trust the sync and enough structure to feed Power BI without querying Paylocity directly.
The immediate business outcome is narrow: ship time-entry pull this week. That means the first slice should cover the minimum reference data needed to contextualize time entries, the time-entry ingestion path itself, sync tracking tables, and validation queries. It should not try to turn into a full data platform, generalized warehouse, or multi-source reporting system yet.
This should be treated as a Monti platform workload, not a standalone side project. The current
spec is a useful functional reference, but the implementation home should follow the monorepo
strategy already established in monti-platform.
ADR 001 already commits Monti to a monorepo for infrastructure, applications, and services.
The repo also already has Azure SQL, Key Vault, monitoring, and environment-root patterns. A
separate monti-reporting-db repository would create another lifecycle to manage for a
one-operator team and would break the repo's "agents can reason across the full system" rule.
The delivery target also argues for a thinner first release. Time-entry sync plus validation solves the stated reporting problem. Employee sync, webhooks, broader warehouse modeling, and more elaborate runtime flows are useful, but they are not required to prove business value this week.
monti-reporting-db repository with Terraform, SQL migrations, and Azure
Functions exactly as laid out in the spec.001, duplicates repo and CI overhead, makes platform-wide reasoning
and shared module reuse harder.monti-platform as a new integration service with Terraform-managed
Azure resources, Azure SQL reporting tables, and a timer-based sync for time entries.Use Approach 2 for the first implementation pass.
The right first outcome is a trustworthy time-entry reporting feed inside monti-platform.
Design the schema and sync audit tables so richer employee dimensions or webhook-driven freshness
can be added later, but do not make first delivery depend on them.
monti-platform, not as a new standalone repo.monti-platform.SyncRun, SyncEvent, and SyncWatermark
are part of the core product because trust in the feed matters as much as the feed itself.Build the first Monti-owned reporting sync for Paylocity time-entry data inside
monti-platform. The week-one outcome is narrow on purpose:
This plan intentionally does not implement the full standalone spec as written. The spec is useful source material, but the chosen scope is smaller and the implementation home is the monorepo.
Monti needs reporting-grade time-entry data from Paylocity this week, but no production sync exists yet. The provided standalone spec assumes:
monti-reporting-db repositoryThose assumptions no longer fit the repo strategy or current platform guidance well enough:
001 keeps new workloads in the monorepomonti-platform.dev smoke path is proven.dev and prod environments for now.dev only in practice. Do not bootstrap or deploy prod before the first
successful dev sync and data validation.infrastructure/modules/ and infrastructure/environments/.services/README.md expects each backend integration to live in its own subdirectory with a
short README covering purpose, interfaces, and deployment.infrastructure/modules/azure_sql/main.tf is still a placeholder, so this work must turn that
module into a real contract before any environment root can use it for this service.v2026-02-17), and it is a
three-step pattern:
/token endpoints and
expiring after 3600 seconds.pyodbc connection-collision
problems and points to mssql-python as Microsoft’s official Python driver for SQL Server and
Azure SQL, but that driver is still relatively new enough that the plan should keep a fallback
path available.Implement a new backend integration service in the monorepo that runs on Azure Functions and persists reporting-grade time-entry data into Azure SQL.
Create a new service root:
services/paylocity-time-entry-sync/
├── README.md
├── host.json
├── requirements.txt
├── local.settings.json.example
├── function_app.py
├── shared/
│ ├── paylocity_client.py
│ ├── db.py
│ └── hashing.py
├── sql/
│ ├── 001_create_reference_tables.sql
│ ├── 002_create_time_entry_tables.sql
│ ├── 003_create_sync_tables.sql
│ └── 004_create_reporting_views.sql
└── tests/
├── fixtures/
├── test_paylocity_client.py
├── test_time_entry_mapping.py
└── test_watermark_logic.py
Key differences from the standalone spec:
services/, not in a separate repoUse this explicit split:
dev.Do not use Terraform null_resource or provisioners to apply application schema. That would
blur infrastructure and application ownership and create drift risk later in prod.
Create two environment roots with a shared-database boundary:
infrastructure/environments/monti-reporting/
├── dev/
└── prod/
infrastructure/environments/paylocity-time-entry-sync/
├── dev/
└── prod/
Only dev/ and prod/ are planned for now. Create and use prod/ only after dev is proven.
Expected Terraform shape for dev:
monti-reporting/dev
azure_sql modulepaylocity-time-entry-sync/dev
Use:
Default database client direction:
mssql-pythonpyodbc + ODBC Driver 18 with singleton connection handling if mssql-python
packaging or runtime stability proves weak in Flex Consumption during the dev spikeDo not plan on Linux Consumption for this new workload unless a later, explicit exception is documented in an ADR.
The standalone spec assumes a simple time-entry GET endpoint. Current Paylocity docs indicate the actual reporting flow is the company punch-detail operation pattern. Before schema and runtime work gets too deep, validate these inputs against a real client tenant or sandbox:
This phase is short but mandatory because it prevents building the wrong table keys.
It should also end with two concrete decisions before broad implementation starts:
mssql-python behaves reliably enough in the chosen Functions hosting model,
or whether the service should switch immediately to the documented pyodbc fallbackservices/paylocity-time-entry-sync/requirements.txt, host.json,
local.settings.json.exampleinfrastructure/environments/paylocity-time-entry-sync/dev/main.tflocals.tfvariables.tfoutputs.tfproviders.tfterraform.tfexample.tfvarsREADME.mdinfrastructure/modules/azure_sql/ with real resources and outputsstorage, key_vault, monitoring, and role_assignments where they already fitentra_app and app_service_auth because this is not a user-facing web appAdd an ADR for the reporting-sync runtime and hosting shape, covering:
Follow the requisition-workflow ownership pattern:
Week-one decision:
dev code deployment may be manual if needed to prove the packaging/runtime path quickly.github/workflows/deploy-paylocity-time-entry-sync-dev.ymlThat workflow should:
devWeek one does not need the full employee model from the original spec.
Use a deliberately smaller schema:
Company
TimeEntry
SyncRun
SyncWatermark
Defer for later:
EmployeeEmployeePositionSyncEventIntentional narrowing note:
SyncEvent as part of broader core observability, but this week-one plan
defers the table because there is no inbound webhook/event stream in scope yetSyncRun, while step-level troubleshooting lives in structured
logs and Application Insights for the first sliceUse repo-owned versioned SQL files plus a migration runner, not manual ad hoc execution.
Expected pattern:
services/paylocity-time-entry-sync/sql/SchemaMigrationprod with the same operational patternThis keeps migration ownership explicit and avoids inventing a new release process for every environment.
The Company table should be seeded by versioned SQL in the repo, not by portal edits or
one-off manual SQL sessions.
Expected pattern:
sql/seed/001_seed_companies.sqlTimeEntry contractThe exact columns should follow the real payload, but the week-one plan should expect at least:
CompanyIdSourceEntryKeyPaylocityEmployeeIdEntryDateClockInClockOutTotalHoursRegularHoursOvertimeHoursDepartmentCodeCostCenterCodeEarningsCodeShiftSourcePayloadHashCreatedAtUpdatedAtImportant modeling rule:
SourceEntryKey from a
deterministic combination of source fields and document that choiceerDiagram
COMPANY ||--o{ TIME_ENTRY : scopes
COMPANY ||--o{ SYNC_RUN : syncs
COMPANY ||--o{ SYNC_WATERMARK : tracks
COMPANY {
string CompanyId PK
string CompanyName
string FacilityCode
string FacilityName
string TimeZone
bool IsActive
}
TIME_ENTRY {
bigint TimeEntryId PK
string CompanyId FK
string SourceEntryKey UK
string PaylocityEmployeeId
date EntryDate
datetime ClockIn
datetime ClockOut
decimal TotalHours
decimal RegularHours
decimal OvertimeHours
string DepartmentCode
string CostCenterCode
string EarningsCode
string Shift
string SourcePayloadHash
}
SYNC_RUN {
int SyncRunId PK
string CompanyId FK
string RunType
datetime StartedAt
datetime CompletedAt
string Status
int RecordsProcessed
int RecordsInserted
int RecordsUpdated
int RecordsSkipped
string ErrorMessage
}
SYNC_WATERMARK {
int WatermarkId PK
string CompanyId FK
string DataType
datetime LastSyncTimestamp
int LastSyncRunId FK
datetime UpdatedAt
}
Build only the timer-based path for week one.
devSyncRun per companyThe client should implement the company punch-detail flow, not a guessed direct endpoint:
Use the auth flow documented by Paylocity:
Authorization headerExpected configuration shape:
PAYLOCITY_CLIENT_ID from Key VaultPAYLOCITY_CLIENT_SECRET from Key VaultPAYLOCITY_TOKEN_URL as an environment-specific app settingPAYLOCITY_BASE_URL as an environment-specific app settingKnown token endpoints from the current docs:
https://dc1demogwext.paylocity.com/public/security/v1/tokenhttps://dc1prodgwext.paylocity.com/public/security/v1/tokenThe implementation must also:
Week-one logic should be explicit:
SyncWatermarkSyncRunCadence and backfill decisions:
0 5 * * * *The sync must be idempotent:
Validation is part of the product, not cleanup work.
Create SQL queries that confirm:
SourceEntryKeyAfter the first dev deployment:
Only after the SQL output validates cleanly:
The spec-flow pass surfaced several gaps that the plan must close:
Endpoint shape is not the one assumed in the standalone spec. The plan must use Paylocity’s current company punch-detail operation flow.
Idempotency key is not yet proven. Schema freeze depends on confirming the actual source record identifier or deriving a stable composite key from sample payloads.
Time-zone boundaries matter. Paylocity’s company-level punch-detail filtering uses date/time windows that need explicit handling per company/facility time zone.
Week-one scope does not guarantee employee dimension data. Any reporting that depends on employee enrichment must be deferred or handled with denormalized fields already present in the punch data.
Partial failures need a predictable recovery contract. The plan must define timeout, retry, watermark, and replay behavior before coding.
Dev region must stay explicit if Azure regional capacity blocks the default.
dev now uses centralus so the Function App and shared reporting database stay co-located
after eastus2 Azure SQL provisioning was blocked for this subscription. If region support
changes again, the repo defaults must be updated instead of relying on hidden workspace
overrides.
Rejected for now because it conflicts with ADR 001, expands scope unnecessarily, and would
duplicate infrastructure and workflow overhead for a single small-team service.
Rejected for now because the user explicitly narrowed week-one scope to time-entry pull only. It also adds dependency on webhook enablement and more schema work before the first reporting slice proves value.
services/paylocity-time-entry-sync/ with a clear README and local
development contract.dev environment root exists under
infrastructure/environments/paylocity-time-entry-sync/dev/.dev environment can provision the service’s Azure resources through Terraform.SyncRun and SyncWatermark correctly reflect successful, empty, and failed per-company
runs.terraform fmt -recursive infrastructureterraform validate for the new environment roottflint --chdir=infrastructure --recursivedev deployment completes successfullydev Function App deploys| Risk | Impact | Mitigation |
|---|---|---|
| Real Paylocity punch-detail payload differs from the standalone spec assumptions | schema churn or wrong uniqueness contract | make payload confirmation Phase 0 and do not freeze table keys before it |
| Flex Consumption is unavailable in the preferred region | infra plan blocked or region drift | check supported regions first and document any deviation immediately |
mssql-python proves immature or unstable in the chosen runtime | delayed first deploy | test it in Phase 0 and fall back quickly to pyodbc + ODBC Driver 18 if needed |
| Key Vault and RBAC bootstrap timing breaks first apply | failed Terraform run | reuse the repo’s proven runner-permission and RBAC propagation patterns |
| Scope creep pulls employee sync into week one | delayed reporting outcome | keep employee and webhook work explicitly out of the first implementation slice |
| SQL authentication becomes the fastest but weakest path | long-term credential debt | prefer managed identity; if a temporary SQL secret is required, log it as a follow-up hardening task |
docs/solutions/ immediately after
they are resolveddocs/brainstorms/2026-03-23-paylocity-reporting-sync-brainstorm.mddocs/decisions/001-monorepo-strategy.mdservices/README.mdinfrastructure/modules/README.mdinfrastructure/modules/azure_sql/main.tfinfrastructure/environments/requisition-workflow/README.mddocs/solutions/integration-issues/requisition-workflow-dev-apply-quota-and-key-vault-rbac-failures-20260319.mddocs/solutions/integration-issues/requisition-workflow-bootstrap-auth-and-naming-hardening-20260319.mddocs/solutions/integration-issues/azure-ad-oidc-bootstrap-permission-failures-20260316.mddocs/solutions/workflow-issues/terraform-lint-module-stubs-requisition-workflow-20260319.md/Users/matt.hartman/Downloads/monti-reporting-db-build-spec.mdhttps://developer.paylocity.com/integrations/reference/post_apihub_time_v2_companies_companyid_punchdetailshttps://developer.paylocity.com/integrations/reference/authenticationhttps://learn.microsoft.com/en-us/azure/azure-functions/consumption-planhttps://learn.microsoft.com/en-in/azure/azure-functions/flex-consumption-how-tohttps://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-azure-sqlhttps://learn.microsoft.com/en-us/azure/azure-functions/recover-python-functionsmssql-python release notes:
https://github.com/microsoft/mssql-python/releasesThe Paylocity service environments originally accepted GitHub deploy app client IDs through
deployer_client_ids. Terraform then looked up each enterprise application with
data.azuread_service_principal so it could assign Azure roles.
That worked only if the Terraform Cloud workspace identity had Microsoft Entra directory-read permissions. During the first production bootstrap, it did not. The plan failed before any Azure resources could be created, even though the actual Azure RBAC work only needed the enterprise application's service principal object ID.
monti-platforminfrastructure/environments/paylocity-time-entry-sync/devinfrastructure/environments/paylocity-time-entry-sync/prodpaylocity-time-entry-sync-prod9088cb5b-3d91-483d-8972-d0a5e39fec1a11821858-d65a-45f6-8d0b-6107110786e0Terraform failed during plan with:
Error: Listing service principals for filter "appId eq '9088cb5b-3d91-483d-8972-d0a5e39fec1a'"
unexpected status 403 (403 Forbidden) with error:
Authorization_RequestDenied: Insufficient privileges to complete the operation.
The failure happened before resource creation because the azuread provider lookup ran at plan
time.
Original contract: pass GitHub deploy app client IDs into deployer_client_ids.
Dev workaround reused in prod: clear the deployer list and grant roles out of band.
Change the environment contract to accept enterprise application service principal object IDs
directly and drop the azuread provider from these roots.
deployer_client_ids with deployer_principal_idsThe variables now take the object IDs Azure RBAC actually needs:
variable "deployer_principal_ids" {
description = "Service principal object IDs for deployment identities ..."
type = list(string)
default = []
}
The roots no longer query data.azuread_service_principal at plan time. Role assignments now use
the provided object IDs directly:
[
for principal_id in var.deployer_principal_ids : {
scope = local.reporting_environment.sql_server_id
role_definition_name = "SQL Server Contributor"
principal_id = principal_id
}
]
That eliminated the need for the azuread provider in these environment roots.
Use Azure CLI once during bootstrap:
az ad sp show --id 9088cb5b-3d91-483d-8972-d0a5e39fec1a --query id -o tsv
Then store that object ID in the Terraform Cloud workspace variable:
deployer_principal_ids = ["11821858-d65a-45f6-8d0b-6107110786e0"]
The broken design mixed two separate concerns:
Terraform only needed the second input. By forcing it to derive that from the client ID at plan time, the environment inherited an unnecessary dependency on Microsoft Entra directory reads.
Using service principal object IDs directly keeps the RBAC model reproducible in Terraform without requiring the Terraform runner to have extra Graph privileges.
azuread provider in environment roots unless a real directory read is part of the
product contract, not just bootstrap convenience.