Skip to content

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:

$ kubectl get pods -n tend-system
NAME                             READY   STATUS    RESTARTS   AGE
data-gateway-6f6c99d7b4-x2m4p    1/1     Running   0          5m
tend-operator-7c9f5c6d55-kq8vn   1/1     Running   0          5m

Step 1 — from prompt to running sandbox app

One target runs the whole create flow:

$ make local-demo APP=expense-helper PROMPT="a form to log expenses with a synthetic table"

Five things happen, faithful to the production spine with the delivery machinery bypassed:

  1. 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 a PROVENANCE file 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.
  2. The generated app is built with docker build and loaded into kind — standing in for Tekton building and Harbor storing the image.
  3. An App CR named expense-helper is applied to tend-system, declaring an owner, an expiry, and one data scope (synthetic, readwrite). The operator provisions the workload namespace tend-app-expense-helper, wires the data-gateway discovery ConfigMap into it, and sets status.state=sandbox.
  4. The app's Deployment and Service are rendered from operator/internal/appscaffold/files/ and applied into that namespace — standing in for Argo CD.
  5. 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 placeholder sha256: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:

$ kubectl get app expense-helper -n tend-system -o yaml
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:

  • spec is the contract. owner, expiry, and dataScopes say 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.
  • status is the operator's honest account. state: sandbox, the provisioned namespace, and the recorded sandbox artifact with its provenance (prompt hash, model — here dry-run/no-model, because no model ran).
  • The conditions do not lie. Ready=True and BuildSucceeded=True, but Isolated=False with reason NetworkPolicyCRDAbsent: 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 is True and backed by Cilium. AIActTriage=False is the decisions-about-people flag, reconciled from spec.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=False was 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.