Skip to Content
ReferenceConstitution

Constitution

The constitution is the Rego policy TruStacks writes and every proposal is judged against. It is Apache 2.0, lives in the public trustacks-policy repository, and is published as a signed OCI artifact. Free at all tiers.

The runner evaluates it with OPA inside your cluster, after the DevOps Engineer emits a proposal and before anything is written to your platform repo. A proposal with any deny never becomes a pull request.

The rules

Twenty-four rules in three kinds. The kind decides what a rule can do. Eight of them apply only to ECS Fargate targets (Beta), and four only to Kubernetes targets; see Which targets a rule applies to.

KindCan refuse a proposalFeeds the maturity score
GateYesNo
BehaviorYesYes, as a practice row
PostureNo, neverYes, as a tooling row

Gate rules

Is the artifact the right shape, in the right place?

Rule IDWhat it checks
proposal.allowed_pathsEvery file lives under gitops/, argo-apps/, or .github/workflows/.
proposal.no_path_traversalNo path is absolute or contains a .. segment.
proposal.has_workflowThe proposal includes at least one .github/workflows/*.yaml file.
proposal.has_helm_chartKubernetes, Helm renderer: at least one Chart.yaml under gitops/.
proposal.has_kustomizationKubernetes, Kustomize renderer: at least one kustomization.yaml under gitops/.
proposal.has_argocd_applicationKubernetes: at least one argo-apps/argo-apps-<cluster>/<service>-application.yaml.
argocd.repoURL_is_canonicalEvery ArgoCD Application’s spec.source.repoURL equals your platform repo URL.
proposal.has_ecs_task_definitionECS Fargate: a gitops/<application>/<target>/<service>/task-definition.json.
proposal.has_ecs_deploy_workflowECS Fargate: a workflow that deploys the task definition with aws-actions/amazon-ecs-deploy-task-definition.

Only the renderer check that matches your Application’s renderer applies, and only on a Kubernetes target. A Fargate target has no renderer.

Behavior rules

Is the artifact doing the right thing? Each one maps to a practice key in your environment profile.

Rule IDWhat it checksPractice
practice.workflow_has_test_stepEvery CI workflow has a run: step that runs tests: pytest, npm test, yarn test, pnpm test, dotnet test, go test, or a Maven test/verify or Gradle test/check/build.tests_in_ci
practice.workflow_has_lint_stepEvery CI workflow has a lint or static-analysis step: ruff, eslint, biome, golangci-lint, go vet, staticcheck, dotnet format, checkstyle, spotless, spotbugs, pmd, or -Dlint.linting_in_ci
practice.argocd_prod_requires_manual_syncKubernetes: when the proposal’s target cluster has tier prod, its ArgoCD Application has no spec.syncPolicy.automated, whatever the cluster is named. Runners older than 0.2.14-rc25 send no tier, so the rule falls back to argo-apps/argo-apps-prod/ (a cluster named prod).manual_promotion_to_prod
practice.dockerfile_runs_as_nonrootEvery Dockerfile in the proposal has a USER that is not root or 0.non_root_containers
practice.workflow_pins_action_versionsEvery third-party uses: pins a full 40-character commit SHA. actions/* and local ./ actions are exempt.supply_chain_pinning
practice.ecs_task_runs_as_nonrootECS Fargate: every container sets user, and it is not root, 0, root:* or 0:*. (AWS Security Hub ECS.20)non_root_containers
practice.ecs_task_readonly_root_filesystemECS Fargate: every container sets readonlyRootFilesystem: true, spelled exactly that way. (ECS.5)none yet
practice.ecs_task_secrets_not_in_environmentECS Fargate: no environment entry whose name or value looks like a credential, and every secrets[].valueFrom is a full Secrets Manager or SSM ARN. (Stricter than ECS.8)none yet
practice.ecs_task_image_is_pinnedECS Fargate: every image has a digest or a non-floating tag (not latest, main, prod and the like, and not untagged). A no-floating-tags check, not a pin: see below.none yet
practice.ecs_task_configures_loggingECS Fargate: every container sets logConfiguration.logDriver. (ECS.9)none yet
practice.workflow_prod_deploy_requires_environmentECS Fargate, prod-tier target: every job that runs amazon-ecs-deploy-task-definition declares a non-empty environment:.none, deliberately

The test-step and lint-step rules judge CI workflows. A Fargate deploy workflow applies an artifact and has no suite to run, so it is skipped; the CI workflow beside it is still judged.

Why most Fargate rules score nothing yet. A practice key is a row on every customer’s score. Adding one that only Fargate can check would add a row Kubernetes customers could only self-attest, and lower their coverage for a check that does not apply to them. So these rules refuse bad Fargate proposals today and score nothing. ecs_task_runs_as_nonroot is the exception: its intent is the same as the Dockerfile rule’s and it is enforced in the artifact, so it feeds non_root_containers.

Why the prod-environment rule never scores. It proves the deploy job names a GitHub environment. Whether that environment has required reviewers is a repository setting TruStacks does not read, and on private repositories it depends on your GitHub plan. If the rule’s silence counted as “a human approves promotion to prod: verified”, the score would claim something nobody checked. For a Fargate Application, that practice can only be claimed, by your attestation.

Tags are not image identity. A commit-SHA tag can be overwritten by anyone who can push to your registry, and ECS resolves the tag when it deploys. ecs_task_image_is_pinned guarantees there is no floating tag. It does not guarantee the image that runs is the one that was reviewed. Until deploys pin a digest, that guarantee does not exist.

Which targets a rule applies to

Every rule declares the target kinds it applies to in its metadata (target_kinds), beside the tiers it applies to (tier_scope). A rule without target_kinds applies to every kind. For the constitution rules below this is descriptive: each checks the kind in its own Rego. For your own rules the gate enforces it (see Where your rule applies).

target_kindsRules
kubernetesproposal.has_helm_chart, proposal.has_kustomization, proposal.has_argocd_application, practice.argocd_prod_requires_manual_sync
ecs-fargateproposal.has_ecs_task_definition, proposal.has_ecs_deploy_workflow, the five practice.ecs_task_* rules, practice.workflow_prod_deploy_requires_environment
anyeverything else

argocd.repoURL_is_canonical declares no kind, but it only inspects ArgoCD Applications, so it never fires on a Fargate proposal. The runner tells the policy which kind and tier a proposal targets (input.context.target). A target with no kind is Kubernetes, so policy judges older runners exactly as before. A policy bundle older than 1.3.0 knows nothing about Fargate and refuses a Fargate proposal for having no ArgoCD Application: it fails closed.

Posture rules

Do you run the tools? These never refuse a proposal. A missing scanner is a gap in your posture, not a broken artifact, so it shows up in your gap report and your score instead of blocking delivery.

Rule IDCategory it scores
posture.image_scanning_declaredimage_scanning
posture.sast_sca_declaredsast_sca
posture.secret_scanning_declaredsecret_scanning
posture.sbom_signing_declaredsbom_signing

The naming rule

constitution.overlay_rule_naming checks the IDs of your own overlay rules against the rule-naming standard. It judges overlay rule metadata, not proposals.

The Rules page (#/rules) lists every rule your runner reports, with its description, tooling categories, and Rego source. Filter by Structural (gate) or Behaviors.

How the score uses the rules

Two rule fields drive the maturity score:

  • required_tooling_categories puts a tooling row on the scorecard. Today that is six categories: ci_cd_platform (from proposal.has_workflow), gitops_controller (from proposal.has_argocd_application), and the four posture categories.
  • practice_dimensions puts a practice row on the scorecard: the five practice keys above. Two rules feed non_root_containers (the Dockerfile rule and the ECS task rule); the other Fargate rules feed none yet.

Gate rules with neither field are enforcement only. They never move your score.

How the policy reaches your runner

The constitution is published from the trustacks-policy repository.

Artifactghcr.io/trustacks/policy/constitution
Current version1.3.0, also tagged 1 and latest
Signed byhttps://github.com/TruStacks/trustacks-policy/.github/workflows/publish.yml@refs/tags/v<version>
SigningSigstore keyless, Rekor transparency log entry per release

Hosted runners follow the :1 tag. Fixes in the 1.x line reach your runner on its next start, without a runner upgrade. A 2.0 that needs a newer runner never arrives on a tag your runner follows.

At start, the runner’s load-policy-bundle init container resolves :1 to a digest, pulls that digest, and verifies its signature. If anything fails it falls back to the copy of the constitution baked into the runner image and reports why. See Policy in force.

⚠️

Runner versions. Signed-policy pull for hosted runners ships in runner 0.2.14 and later (and pre-releases from 0.2.14-rc24). Earlier runners, including 0.2.13, enforce the copy baked into the runner image and do not verify a signature at start.

Policy versions independently of the product. The constitution is on the 1.x line; runner and control-plane releases are on 0.2.x. See Versioning.

Verify the policy yourself:

cosign verify \ --certificate-identity-regexp '^https://github\.com/TruStacks/trustacks-policy/\.github/workflows/publish\.yml@refs/tags/v.*$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ghcr.io/trustacks/policy/constitution:1

Your own rules

Overlay rules let you add your own reasons to refuse a proposal. Their denies are unioned into the constitution’s (data.proposal.deny), so an overlay can only add refusals. It has no way to cancel a constitution deny.

From runner 0.2.14 this is enforced, not just a property of how the policy is assembled. Before it evaluates anything, the runner checks that the overlay only adds to data.overlay. Earlier runners did not check this.

What an overlay may contain

This is what a signed overlay bundle may contain. Rules loaded from your platform repo are narrower: Rego only, tests left out. See Rules from your platform repo.

  • Rego files (*.rego, tests included, because they are loaded too) must declare package overlay.<name>. The package is read by OPA’s own parser, not a pattern match; a file OPA does not attribute to a package is a violation.
  • Data files (*.json, *.yaml, *.yml) only under overlay/, so they land in data.overlay. At the root, only opa build’s data.json and the rule_inventory.json that trustacks rule sign adds are allowed.
  • No symlinks. The tree must be exactly what was signed.
  • Nothing else, except a root .manifest and *.md / *.txt docs. This is an allowlist: any other file type (a nested *.tar.gz, for example) is refused.

Rule ids must also follow the rule naming standard: the namespaces argocd, constitution, posture, practice, proposal and trustacks are reserved for TruStacks.

What a violation looks like

For a signed bundle, the runner gives no verdict for a proposal or gap analysis while the overlay is unconfined. The run fails with overlay rejected — overlay rules may only add to data.overlay: <each violation>, and the runner logs policy.overlay_rejected with the violations. Fix the overlay, re-sign it, and run again.

Rules from your platform repo are handled differently: the run continues under the constitution alone and records why your rules were refused. See Rules from your platform repo.

trustacks rule lint and trustacks rule sign run the same check, so you see the problem before the runner does, and sign refuses to publish an unconfined overlay. trustacks rule new --package must start with overlay.. See the Rule CLI and the policy linter.

Rule metadata

Required from runner 0.2.19. Earlier runners read description, required_tooling_categories, practice_dimensions and tier_scope, and derive the kind.

Every overlay rule declares a rule_metadata map with one key, its rule id, and these fields:

FieldRequiredValues
descriptionYesOne sentence a reviewer can read.
kindYes"structural" or "behavior".
required_tooling_categoriesYesTooling categories the rule reads from your Stack file (data.env.tooling_categories.*), or [].
practice_dimensionsFor behaviorAt least one practice key for a behavior rule. Empty, or left out, for a structural rule.
tier_scopeYes["any"], or a list of tiers from "dev", "uat" and "prod".
target_kindsYes["any"], or a list of target kinds such as "kubernetes" and "ecs-fargate".
subjectsNo["proposal"] (the default) or ["proposal", "repo"]. See Your rules in the repo audit.

"any" can’t be mixed with other values: ["any", "prod"] is refused.

A rule’s PR body (from Propose as PR) calls it one of three things. Each maps to the fields like this:

You call itkindTooling and practice fields
A practice rule: how the files a change writes behavebehaviorAt least one practice_dimensions entry
A posture rule: your Stack file declares a toolstructuralThe categories it reads in required_tooling_categories
A structural rule: the shape of a changestructuralNeither

A declared kind is used as declared. A rule doesn’t need a practice. id to be a behavior rule (it can’t have one: the practice namespace is reserved).

Where your rule applies

Available from policy 1.11.0 / runner 0.2.21. Runners follow the policy release on its own; runner 0.2.21 also carries it in its built-in policy. Before that, tier_scope and target_kinds were descriptive only, and a rule meant for production had to check input.context.target.tier in its deny body.

For your rules, the gate enforces tier_scope and target_kinds. A rule’s denies count only when the proposal’s tier is in its tier_scope and its target kind is in its target_kinds. Outside that scope the rule is skipped, and the PR body states the scope. You don’t need to check input.context.target.tier in the deny body (a rule that still does keeps working).

  • ["any"] applies everywhere. So does a rule that doesn’t declare the field.
  • A missing or invalid scope means “any”. A value outside the schema (["staging"], [], "prod" not in a list, ["any", "prod"], a target kind the constitution doesn’t know) widens the rule to every tier or kind. The rule still enforces, and the value shows as a metadata_invalid warning. A mistake in the scope never switches a rule off.
  • No tier means a tier-scoped rule is skipped. When a proposal names no tier, a rule scoped to specific tiers can’t be shown to apply. A proposal with no target kind is Kubernetes, as it is for every constitution rule.
  • The repo audit has no deployment target. A rule scoped to a specific tier or target kind is skipped in the repo audit. A rule you want in the audit uses ["any"] for both.
  • Constitution rules keep their own logic. Their tier_scope and target_kinds describe them; each checks tier and kind in its own Rego.
rule_metadata := { "acme.prod_deploy_requires_approval": { "description": "Production deploy jobs run in an approval environment", "kind": "behavior", "required_tooling_categories": [], "practice_dimensions": ["prod_deploy_requires_approval"], "tier_scope": ["prod"], "target_kinds": ["any"], }, } rule_index := rule_metadata

Refused when you write it, never switched off when it runs. Metadata problems are caught while you author a rule. Once a rule reaches a runner, a metadata problem never turns it off:

  • A value outside the schema (kind: "posture", tier_scope: ["staging"], a behavior rule with no practice dimensions, a structural rule that lists some), or one rule id declared in two packages, is refused by trustacks rule lint, by trustacks rule sign, and by the checks on every Coordinator draft.
  • A required field that isn’t declared is refused by trustacks rule lint and for a new Coordinator draft.
  • If such a rule still reaches a runner, through rules/ in your platform repo or a signed bundle, it still loads and enforces. A missing or invalid kind falls back to the derived kind (behavior if it lists practice dimensions, otherwise structural), and each problem shows as a warning (metadata_invalid, or for a missing field a message such as acme.foo: rule_metadata does not declare kind, tier_scope, target_kinds. Its kind was derived as structural, as before.). The warning is in the overlay record on policy_check, gap_check and policy_audit, in your runner’s policy state report, and on the rule in the Rules API (warnings). Fix the metadata to clear it.

What does stop an overlay loading is a safety check, not metadata: a rule outside data.overlay, a compile failure, the size cap, a symlink, or (for rules/ in your platform repo) one rule id in more than one package. Then none of the overlay loads, the constitution still applies, and the reason is recorded on the run. See Rules from your platform repo.

Overlay rules and your score

Overlay rules are enforced on every proposal, but they do not count toward your maturity headline. The tier, coverage and verified figures are computed from constitution rules alone. Rows that only your overlay rules ask for are listed on Maturity under Your rules, outside the score. An overlay can still lower a constitution row, never raise one. See Maturity scoring.

Rules from your platform repo

Available from runner 0.2.17. Earlier hosted runners load no overlay rules from your repo.

On the hosted service your overlay rules live in your platform repo, under rules/. Each rule is a directory, the layout trustacks rule new creates:

rules/<rule_id>/<rule_id>.rego # package, rule_metadata, deny rules/<rule_id>/<rule_id>_test.rego # opa tests

On every run that evaluates policy (a proposal’s policy check and its advisory gap check, a gap analysis, a rule-impact check, and the repo audit of an analysis), the runner reads rules/ from your platform repo’s default branch, at the same commit it reads environment-profile.yaml from. There are no keys, no registry and no Helm values to set. The trust anchor is your own Git: branch protection, pull-request review and history. That is proportionate because an overlay can only add denies and is confined to data.overlay: the worst a bad rule can do is block your own proposals, which anyone who can push to your default branch could already do.

What the runner loads. Rego only. Every *.rego file under rules/ except *_test.rego: tests are for trustacks rule test and your own CI, not for the gate. JSON and YAML data files under rules/ are not loaded, so put lists inside the rule itself:

allowed_registries := {"ghcr.io/acme", "us-docker.pkg.dev/acme"}

Every file must declare package overlay.<name>.

What the runner checks first. Before it uses any of it, rules/ must:

  • stay within 256 files and 1 MiB of Rego (tests are not counted);
  • contain no symlinks;
  • pass the same confinement check a signed bundle gets (every package under overlay., nothing outside data.overlay);
  • compile together with the constitution (opa check), under the same restricted builtins the runner evaluates with.
  • declare rule_metadata values within the schema (from runner 0.2.19). See Rule metadata.

All or nothing. If any check fails, none of rules/ loads. The run continues under the constitution alone, and the reason is recorded on the run’s policy_check, gap_check or policy_audit event and in the runner log (policy.repo_overlay_rejected). Nothing is dropped silently. This differs from a signed bundle, where an unconfined overlay stops the run with no verdict.

Restricted builtins. Every opa process the runner starts gets an environment containing only PATH, and the builtins that reach the network, read the environment, or fetch a schema $ref are removed: http.send, net.lookup_ip_addr, opa.runtime, json.match_schema, json.verify_schema and providers.aws.sign_req. A rule that uses one does not compile, so it is refused at the check above. Your rule can read the proposal and profile it is judging, and nothing else.

Recorded on every verdict. policy_check, gap_check, gap_computed and policy_audit carry the overlay that decided them: its source (repo, bundle or none), the commit SHA it was read from, the rule ids loaded, and any warnings or violations. The rules appear on the Rules page as Your overlay.

A signed bundle wins

A signed overlay bundle (policyBundle.overlayRef in the runner chart) still works. When the runner has verified and loaded one, the bundle is the overlay and rules/ in the repo is ignored, with a warning. The two are never merged.

Set overlay.requireSigned: true in the runner chart to refuse the repo source entirely and load only a signed bundle. It defaults to false.

Your rules in the repo audit

Available from runner 0.2.19. Earlier runners audit a repo with the constitution and a signed overlay bundle only.

Every analysis of a service also audits the repo’s own delivery files: its Dockerfiles and Containerfiles, its .github/workflows/*.yml and .yaml files, and its ECS task definitions (up to 200 files, each up to 256 KiB). Whoever wrote them, a person or an AI assistant, they are judged by the same rules as a proposal. It is an audit, not a gate: it never fails the analysis. Its denials arrive on Signals as findings observed by trustacks-policy:repo-audit.

Only rules that declare repo in subjects are part of the audit, constitution and overlay alike. A rule such as the proposal includes a Helm chart means nothing for a service repo, so it stays ["proposal"]. A repo has no deployment target, so from policy 1.11.0 / runner 0.2.21 the audit skips any rule of yours scoped to a specific tier or target kind: give a repo-audited rule tier_scope and target_kinds of ["any"] (see Where your rule applies). To add one of your rules:

"subjects": ["proposal", "repo"],

Declare repo only when a file in the service repo is enough to judge the rule. In the audit, input.context.subject is "repo" and there is no deployment target, so a rule that needs input.context.target doesn’t fire there. An overlay rule that reuses a constitution rule id is not added.

Which overlay. The audit uses the same selection as a proposal: the platform repo of the Application the service belongs to, read at the commit its environment-profile.yaml was read from, with the same checks and sandbox. A signed bundle the runner has verified still takes precedence, and overlay.requireSigned: true still refuses the repo source. If your rules are refused, the platform repo can’t be read, or evaluating your rules fails, the audit still files the constitution’s findings and says why: a problem with your rules never hides TruStacks’ own findings. A service that no Application owns yet is audited with the constitution and any signed bundle.

The policy_audit event records the overlay it used (source, commit SHA, rules, warnings, violations), the same shape as policy_check. A finding from one of your rules reads Your overlay rule <rule_id> (the platform repo’s rules/ at <commit>) denies this file in the repository itself, or names your signed overlay bundle. A finding from the constitution names the signed TruStacks policy rule. The runner tags each denial with the rule set that produced it, so the label always matches its source.

Drafting and testing

  • Drafting and proposing. Type /rule new <id> in the Workspace chat and the Coordinator drafts a rule and its test cases, grounded in your environment profile and the rules already in rules/. TruStacks checks each draft under the runner’s sandbox (compiles with the constitution, confinement, the rule metadata, opa test) before you see it. Propose as PR opens a pull request on your platform repo with the rule, its test and, the first time, a CI job that runs opa test on rules/. A person merges it; TruStacks never does. Needs runner 0.2.18. See Add your own rule.
  • Authoring and testing. trustacks rule new, test, and lint work on a local checkout of your platform repo. See the Rule CLI.

What is not in the constitution

  • Rules about approvals, review counts, or who may merge. Merge control belongs to your git provider’s branch protection.
  • Rules about credentials, data residency, or citations.
  • Waivers. There is no waiver mechanism.
  • SOC 2, HIPAA, PCI, or FedRAMP rules. See Specialist Packs.

Where to go next

Last updated on