Your first governed app¶
In this tutorial we prompt an app into existence on the local platform, read the App CR that governs it, use the app, and then watch the data gateway enforce the app's declared data contract — the allow and the deny. By the end you will have seen contract-not-code work with your own eyes.
Precondition
This continues the local path: the tend-local kind cluster exists and
both platform pods are Ready. Quick check:
Step 1 — from prompt to running sandbox app¶
One target runs the whole create flow:
Five things happen, faithful to the production spine with the delivery machinery bypassed:
- The builder adapter runs on your machine in dry-run mode
(
ADAPTER_DRY_RUN=1): there is no model behind the local path, so it falls back to its built-in reference scaffold and writes the generated app (main.go,go.mod,Dockerfile) plus aPROVENANCEfile to a temp directory. The prompt is hashed and recorded in that provenance — but honestly, the generated code is the same reference app whatever you type. On the full platform this step calls the inference gateway and commits to the app's Gitea sandbox branch. - The generated app is built with
docker buildand loaded into kind — standing in for Tekton building and Harbor storing the image. - An App CR named
expense-helperis applied totend-system, declaring an owner, an expiry, and one data scope (synthetic, readwrite). The operator provisions the workload namespacetend-app-expense-helper, wires the data-gateway discovery ConfigMap into it, and setsstatus.state=sandbox. - The app's Deployment and Service are rendered from
operator/internal/appscaffold/files/and applied into that namespace — standing in for Argo CD. - Build-report annotations are patched onto the App CR — standing in
for Tekton Chains — and the operator records
status.sandboxArtifact. The attestation digest is the placeholdersha256:local-unsigned: this image is not signed, and the local path says so rather than pretending.
Expected output (adapter and build noise trimmed):
[local-demo] Running builder adapter (local emit) for app 'expense-helper'...
[local-demo] Generated files:
Dockerfile
PROVENANCE
go.mod
main.go
[local-demo] Building app image tend-local/expense-helper:latest from generated Dockerfile...
[local-demo] Loading tend-local/expense-helper:latest into kind...
[local-demo] Applying App CR 'expense-helper' to tend-system...
app.tendtech.io/expense-helper created
[local-demo] Waiting for operator to provision namespace tend-app-expense-helper...
[local-demo] App state: sandbox (namespace: tend-app-expense-helper)
[local-demo] Rendering app Deployment/Service from the operator app scaffold and applying...
[local-demo] Patching App CR build-report annotations (operator records sandboxArtifact)...
[local-demo] Waiting for app pod to be Ready...
pod/expense-helper-79b6d5f7c8-w9zlt condition met
[local-demo] US1 Scenario A (local) complete:
state=sandbox
namespace=tend-app-expense-helper
sandboxArtifact.image=tend-local/expense-helper:latest
NAME READY STATUS RESTARTS AGE
expense-helper-79b6d5f7c8-w9zlt 1/1 Running 0 12s
[local-demo] Next: make local-open APP=expense-helper
Note what did not happen: no approval step, no ticket, no human in the loop. Prompt to running sandbox app is deliberately friction-free — the governance lives around the app, not in front of it.
Step 2 — read the App CR¶
The App CR is the single source of truth for this app. Start with the summary columns:
$ kubectl get app expense-helper -n tend-system
NAME STATE OWNER EXPIRY AIACT
expense-helper sandbox u:demo@tend.local 2026-12-31T00:00:00Z
Now read the whole object — this is worth a slow minute:
apiVersion: tendtech.io/v1alpha1
kind: App
metadata:
annotations:
tendtech.io/attestation-digest: sha256:local-unsigned
tendtech.io/built-at: "2026-07-10T09:41:02Z"
tendtech.io/model: dry-run/no-model
tendtech.io/prompt-hash: sha256:4f8a1c... # sha256 of your prompt
tendtech.io/sandbox-image: tend-local/expense-helper:latest
finalizers:
- tendtech.io/cleanup
name: expense-helper
namespace: tend-system
spec:
dataScopes:
- access: readwrite
name: synthetic
sourceType: synthetic
expiry: "2026-12-31T00:00:00Z"
owner: u:demo@tend.local
tier: tier1
status:
attestationRefs:
- attestationDigest: sha256:local-unsigned
builtAt: "2026-07-10T09:41:02Z"
image: tend-local/expense-helper:latest
model: dry-run/no-model
promptHash: 4f8a1c...
conditions:
- message: Workload namespace provisioned and labeled.
reason: NamespaceProvisioned
status: "True"
type: Ready
- message: CiliumNetworkPolicy CRD absent; network isolation deferred
(synthetic-data discovery still wired).
reason: NetworkPolicyCRDAbsent
status: "False"
type: Isolated
- message: Attested sandbox artifact recorded; app running in sandbox
(no approval).
reason: SandboxRunning
status: "True"
type: BuildSucceeded
- message: App is not classified as making decisions about people.
reason: DecisionsAboutPeople
status: "False"
type: AIActTriage
namespace: tend-app-expense-helper
observedGeneration: 1
sandboxArtifact:
attestationDigest: sha256:local-unsigned
builtAt: "2026-07-10T09:41:02Z"
image: tend-local/expense-helper:latest
model: dry-run/no-model
promptHash: 4f8a1c...
state: sandbox
(Timestamps and hashes will differ; lastTransitionTime fields are
elided here.) Reading it top to bottom:
specis the contract.owner,expiry, anddataScopessay who answers for this app, when it lapses, and exactly which data it may touch — one scope,synthetic, readwrite. Nothing else. On the full platform these fields are defaulted at admission by the Kyverno policy baseline when a bare App is created; Kyverno is not installed locally, so the demo script declared them explicitly. Either way the resulting contract is the same shape.statusis the operator's honest account.state: sandbox, the provisioned namespace, and the recorded sandbox artifact with its provenance (prompt hash, model — heredry-run/no-model, because no model ran).- The conditions do not lie.
Ready=TrueandBuildSucceeded=True, butIsolated=Falsewith reasonNetworkPolicyCRDAbsent: the local path has no network isolation, and the platform records that on the CR instead of pretending otherwise. On the full platform this condition isTrueand backed by Cilium.AIActTriage=Falseis the decisions-about-people flag, reconciled fromspec.purpose.
Every field is documented in the App CRD reference.
Step 3 — use the app¶
The app serves a page and reads/writes rows — through the data gateway, never directly:
$ make local-open APP=expense-helper
[local-open] Waiting for app 'expense-helper' to be Ready...
pod/expense-helper-79b6d5f7c8-w9zlt condition met
[local-open] Port-forwarding tend-app-expense-helper/svc/expense-helper:80 -> localhost:18080 ...
[local-open] GET / (expect 200)...
GET / -> 200 OK
[local-open] POST /submit value=local-open-1783862101 (write a row via the data gateway)...
POST /submit -> row written
[local-open] GET / again (verify the row is visible)...
Row 'local-open-1783862101' visible in page
[local-open] PASS: expense-helper serves a page and read/write through the data gateway works (SC-001 / FR-025).
That short transcript is a complete governed round trip:
sequenceDiagram
participant C as local-open (curl)
participant A as app pod
participant G as data gateway
participant S as synthetic store
C->>A: POST /submit value=...
A->>G: POST /v1/sources/synthetic/rows<br/>Bearer <projected SA token>
G->>G: TokenReview → namespace → App CR<br/>scope check → log decision
G->>S: create row
S-->>G: row
G-->>A: 201
A-->>C: 303 → /
The app found the gateway via the DATA_URL the operator rendered into
its namespace
(http://data-gateway.tend-system.svc:8080/v1/sources/synthetic), and
authenticated with a projected ServiceAccount token (audience
tend-data-gateway) that kubelet rotates for it. That token is the
only credential the app holds — apps never see data-source
credentials at all.
Want to click around?
kubectl port-forward -n tend-app-expense-helper svc/expense-helper
18080:80, then open http://localhost:18080 — the form writes
through the same gateway path.
Step 4 — the contract, enforced¶
Now let's be the app for a moment. make gateway-request mints a token
for the app's own ServiceAccount and calls the gateway from inside the
app's namespace — the exact identity and network path the app uses.
First, the declared scope:
$ make gateway-request APP=expense-helper SOURCE=synthetic MODE=read
[gateway-request] Minting a projected token for tend-app-expense-helper/default (audience tend-data-gateway)...
[gateway-request] Requesting GET http://data-gateway.tend-system.svc:8080/v1/sources/synthetic/rows from inside tend-app-expense-helper (as default)...
[gateway-request] status: 200
[gateway-request] body:
{
"rows": [
{
"id": "...",
"data": {
"value": "local-open-1783862101"
},
"createdAt": "2026-07-10T09:44:18Z"
}
]
}
200, and the row we wrote in step 3 is right there. Now an
undeclared source — same app, same token, same gateway:
$ make gateway-request APP=expense-helper SOURCE=postgres-hr MODE=read
[gateway-request] Minting a projected token for tend-app-expense-helper/default (audience tend-data-gateway)...
[gateway-request] Requesting GET http://data-gateway.tend-system.svc:8080/v1/sources/postgres-hr/rows from inside tend-app-expense-helper (as default)...
[gateway-request] status: 403
[gateway-request] body:
{"error":"undeclared-source"}
This is contract-not-code. Nothing in the app's code or network position
changed between the two requests; the only difference is what
spec.dataScopes declares. The deny happened at scope evaluation —
before the gateway even considered whether a postgres-hr backend
exists — and both decisions were logged.
Step 5 (optional) — move the contract, enforcement follows¶
The contract is live, not a deploy-time artifact. Withdraw the scope:
$ kubectl patch app expense-helper -n tend-system --type merge \
-p '{"spec":{"dataScopes":[]}}'
app.tendtech.io/expense-helper patched
$ make gateway-request APP=expense-helper SOURCE=synthetic MODE=read
...
[gateway-request] status: 403
[gateway-request] body:
{"error":"undeclared-source"}
The gateway watches App CRs through a cached informer, so the change takes effect within seconds — and nothing redeployed:
$ kubectl get pods -n tend-app-expense-helper
NAME READY STATUS RESTARTS AGE
expense-helper-79b6d5f7c8-w9zlt 1/1 Running 0 9m
Same pod, zero restarts. Enforcement moved because the declaration moved. Restore the scope before moving on (the app's own reads are being refused right now too, which is exactly the point):
$ kubectl patch app expense-helper -n tend-system --type merge \
-p '{"spec":{"dataScopes":[{"name":"synthetic","sourceType":"synthetic","access":"readwrite"}]}}'
app.tendtech.io/expense-helper patched
$ make gateway-request APP=expense-helper SOURCE=synthetic MODE=read
...
[gateway-request] status: 200
Where the decisions went¶
Every request above — allows and denies — was written to the gateway's
access log before the response was sent. There is no unlogged path:
if the log store is not writable, the gateway refuses to serve at all
(fail closed). The log is queryable per app on the platform-only API
GET /v1/logs — and locally, that surface is fenced off from us too:
$ kubectl port-forward -n tend-system svc/data-gateway 18081:8080 &
$ curl -si "http://localhost:18081/v1/logs/summary?app=tend-system/expense-helper" | head -1
HTTP/1.1 401 Unauthorized
$ kill %1
That 401 is correct behavior, not a broken step: the query API
requires a Keycloak-issued platform token, and the local path runs no
Keycloak. On the full platform the same query answers
{"allows": n, "denies": n, "lastDeny": {...}} with both of today's
decisions in it. See
Query an app's evidence and the
data gateway API reference.
What you did not see locally¶
Three enforcement layers were bypassed on this path. They are real on the full platform, and CI demonstrates the first two live — on pull requests touching the paths its policy workflow filters on, not on every pull request:
- The attestation gate. Our image carried the placeholder digest
sha256:local-unsigned; on the full platform Tekton Chains signs and attests every build, and Kyverno's admission gate refuses anything unsigned. See The evidence chain. - The network perimeter.
Isolated=Falsewas the honest marker; on the full platform Cilium enforces a generated, gateway-only perimeter per app namespace, so the network also blocks what the contract denies. - The promotion PR. Promotion to production is a gated, platform-raised PR with the evidence record attached — it needs Gitea, Argo CD, and the platform-approver group. See Promote an app to production.
The live proofs for the first two run on pull requests that touch the filtered paths — a filter that has been wrong three times, twice letting a change merge green with no policy job run.
Clean up¶
Keep the cluster if you are continuing to the how-to guides — they assume this app exists. Otherwise:
$ make local-down
[local-down] Deleting kind cluster 'tend-local'...
Deleting cluster "tend-local" ...
Deleted nodes: ["tend-local-control-plane"]
You have now taken one app from prompt to running, read the contract that governs it, and watched that contract enforced — and moved — with zero redeploys. That is the core of Tend's governance model; everything else layers depth onto exactly this spine.