diff --git a/content/courses/fountain/07-driving-an-agent-from-an-app.md b/content/courses/fountain/07-driving-an-agent-from-an-app.md index e375d4c..8a3da24 100644 --- a/content/courses/fountain/07-driving-an-agent-from-an-app.md +++ b/content/courses/fountain/07-driving-an-agent-from-an-app.md @@ -30,7 +30,7 @@ activity: ## Context -- An app that drives an agent needs two facts from the server and nothing else. `API_CORS_ORIGINS` admits the origin the page is served from, because a browser calling another origin's API makes a CORS request and it is off by default. `OAUTH_CLIENTS` names the app that may offer Sign in with Fountain, with its redirect URI exact. Both are set for you in `compose/docker-compose.yml`, for `http://localhost:1313` and `http://localhost:8080`, which are `just serve` and `just docker-run`. +- An app that drives an agent needs two facts from the server and nothing else. `API_CORS_ORIGINS` admits the origin the page is served from, because a browser calling another origin's API makes a CORS request and it is off by default. `OAUTH_CLIENTS` names the app that may offer Sign in with Fountain, with its redirect URI exact. Both are set for you in `compose/docker-compose.yml`, for `http://localhost:1313` and `http://localhost:8080`, which are `just serve` and `just docker-run`, and for the copy of this page published on the site, which drives your own stack because a static page carries no server of its own. - Sign in with Fountain is OAuth 2.0 authorization code with PKCE, and the app is a public client with no secret. `GET /oauth/authorize` remembers the request and sends you to login, the consent page names the app asking, and allowing it issues a one time code that lives five minutes and works once. `POST /api/oauth/token` swaps that code plus the verifier for a key. The key **is** an API key, full scope, thirty days, listed under Account and API keys as `oauth:`, and a sign out revokes it. A client or a redirect nobody registered is rendered as an error and redirected nowhere. - The protocol is fenced blocks in the agent's replies. The desk emits `aws-state`, `aws-plan` and `aws-result`, each a fenced code block whose info string is the block name and whose body is one JSON object. `desk/PROMPT.md` is the desk's half of that agreement and `desk/protocol.js` is the page's half, and `desk/bin/check-protocol` fails the build when the two stop naming the same blocks. - The page holds no state a server could lose. Everything on screen is derived from the conversation's turns and its log events, which is why a reload rebuilds it. What the browser keeps is the Fountain URL, the teammate name and the key, in `localStorage`, and nothing else. diff --git a/content/courses/fountain/08-propose-loop-interactive.md b/content/courses/fountain/08-propose-loop-interactive.md index 461ddfb..66b5d60 100644 --- a/content/courses/fountain/08-propose-loop-interactive.md +++ b/content/courses/fountain/08-propose-loop-interactive.md @@ -5,55 +5,145 @@ lesson: 8 weight: 8 summary: "Mend and dns-desk run the propose loop with a person as the propose step." # skill. a directory in this repo with a SKILL.md that drives the lesson. empty renders nothing -skill: "" +skill: "skills/f8-propose-loop-interactive" # card. empty renders as TODO -goal: "" -done_when: "" -restart_from: "lessons 4 and 7" +goal: "Run the whole loop against a real account with the desk from lesson 7. Ask it to plan the estate this repo declares, read the plan as an access delta rather than as Terraform, approve it by name and watch it apply. Then ask for one grant in plain words and read the diff it made. Move the account by hand before you approve that one, and watch the desk refuse its own plan as stale rather than apply what you approved to an estate that has since changed. Ask it to widen the boundary and watch it refuse. Then fill the eight parts of the loop for the desk and say which single part differs from Mend." +done_when: >- + An `aws-plan` block carries a delta, a proof verdict and a digest the desk + copied rather than wrote, `APPROVE` on it applies and the account read back + by hand holds what the delta said it would, a plan approved after you + changed the account by hand comes back as `aws-result` `stale` with the + moved resource named and nothing applied, a request to widen the estate + boundary comes back `refused` with the platform path named, and your parts + table names propose as the one part where the desk and Mend differ. +restart_from: "lesson 7, with the desk on the team" properties: ["IV", "VIII", "XI"] -# media. provider is youtube, vimeo, file or todo -video: - provider: todo - title: "" - length: "" # activity. kind is hands-on, watch-along or discuss activity: kind: hands-on - time: "45 min" - needs: [] + time: "60 min" + needs: ["lesson 7 finished, so the desk is on the team and the page is open", "an inference key set, this lesson makes seven model turns", "jq and curl, and the AWS CLI for reading the account back"] solo: true live: true --- ## Context -- Mend is the audit-driven form. dns-desk is the request-driven form. The parts table is on the propose loop page. -- The agent reads. A person is the propose step. The browser refuses a stale patch. -- The desk re-reads the zone before it applies and re-plans if the zone moved. +- The loop has eight parts and [the propose loop](../../propose-loop.md) names them. Target, read, operator, plan, verify, propose, rules, record, and refusal as an outcome. Mend and dns-desk are the interactive two, one audit-driven and one request-driven, and the desk is dns-desk's form on an AWS estate. +- The plan a person approves is not the Terraform plan. It is the access delta, which says what a principal gains or loses, and `access/scripts/render-delta` writes it out of the plan JSON. The desk copies that text into the block and never composes it, which is decision 14. +- Verify here is a re-plan. The desk saves the plan it showed you, and on approval it plans again and compares digests. A digest that moved means the estate moved under your approval, and the answer is to refuse rather than to apply. +- The digest is over a normalised reading of the saved plan, so two runs of the same change on different machines agree and any change to what would be created, changed or destroyed disagrees. `access/scripts/plan-digest` computes it. +- Direct mode's propose step is an `APPROVE` message in the conversation, and the desk holds the credential that applies it. Mend's propose step is the human's own browser holding the human's own token, and the operator holds nothing that can write. That difference is the whole of this lesson's last step. +- Accessible Ops IV is one path to prod. VIII is escalate the judgement. XI is the live system is the truth, which is why every read here goes to the account and not to the state file. -## Watch +## Do -{{< todo "Video script or link. Optional." >}} +Lesson 7 made the desk talk. This one makes it work, and the loop it runs is the one every later lesson instantiates. -## Do +1. Check the account is empty, so you can watch it fill. If lesson 7 left it empty this prints nothing. + + ```sh + export AWS_ENDPOINT_URL=http://localhost:4566 AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1 + aws iam list-roles --query 'Roles[].RoleName' --output text + ``` + + Keep that shell for the rest of the lesson. Every later read is a bare `aws` call and wants those four variables, so a fresh terminal reads a real account instead of the emulator. + +2. Ask the desk to plan what the repo already declares. This is the first apply of an estate, and it needs no edit at all. + + ``` + Plan the first apply of everything the repo declares. + ``` + + It reads the account, then plans. The block that comes back carries the pieces a reviewer needs in the order they need them. The access delta first, grouped by principal, with the grants each one gains. Then the proofs, which on Floci are one named skip because Access Analyzer is a stub there. Then the file diff, which is empty here because nothing was edited. Then the typed changes read out of `terraform show -json`. + + Read the delta rather than the change list. Eighteen resources is a Terraform fact. Four principals, five grants and a boundary is what somebody is being asked to approve. + +3. Approve it by name, which is the only sentence that approves anything. + + ``` + APPROVE plan-xxxx + ``` + + The desk plans again before it applies, compares the new digest against the one it showed you, and applies the plan you approved rather than the one it just made. Then read the account back yourself, because the live system is the truth and the desk saying so is not the same as the account saying so. + + Read the account rather than the sentence. `detail` is the desk's own words, unlike the delta and the digest beside it, so the count in it is the model reporting rather than a script. One run of this lesson said seventeen added for an eighteen resource plan and the estate was correct anyway. + + ```sh + aws iam list-roles --query 'Roles[].RoleName' --output text + aws iam list-policies --scope Local --query 'Policies[].PolicyName' --output text + ``` + +4. Ask for one grant, in words, the way somebody would ask a colleague. + + ``` + site-publisher needs list on waterpark-artifacts, so the build can see which checkpoint bundles exist before it picks one. + ``` + + Now the block carries a diff, because this time the desk edited a file. One entry added to the `grants` list in `access/envs/prod/iam_role.site_publisher.tf`, with the reason you gave it carried into the file. Two resources to create, which are the policy and its attachment. Note the plan id and do not approve it yet. + + That file is in the desk's own clone, inside its sandbox, and not in your checkout. The diff in the block is the only sight of it you get, which is a fact about direct mode worth noticing now rather than in step 9. -{{< todo "Numbered steps. Imperative. One job." >}} +5. Move the account under the approval, by hand, the way a console click would. Take away a grant the desk is not touching. -1. {{< todo >}} -2. {{< todo >}} -3. {{< todo >}} + ```sh + ARN=$(aws iam list-policies --scope Local \ + --query 'Policies[?PolicyName==`site-publisher-list-waterpark-site`].Arn' --output text) + aws iam detach-role-policy --role-name site-publisher --policy-arn "$ARN" + aws iam delete-policy --policy-arn "$ARN" + ``` + +6. Now approve the plan from step 4. + + ``` + APPROVE plan-xxxx + ``` + + It refuses. The re-plan's digest does not match the digest you approved, so the desk discards the plan and comes back with `aws-result` `stale`, naming the grant that vanished and what a fresh plan would now do instead. Nothing was applied. This is the part of the loop worth watching, because the approval was real, the desk was willing, and the estate had moved. + + Say what you would have got without it. The saved plan would have applied two resources you approved and recreated one you never saw, on an estate nobody described to you. + +7. Let it recover. Ask for a fresh plan, read it, and approve that one. + + ``` + I removed that policy by hand. Re-plan and show me the new plan. + ``` + + The new plan has four changes rather than two, because it restores what you deleted as well as adding what you asked for. Approve it and read the account back again. + +8. Ask for something it must refuse. + + ``` + The boundary is blocking me. Widen it to allow iam:* so I stop hitting this. + ``` + + That is a change to `access/baseline`, which every role in the estate stands inside, so it is not one principal's grant and not the desk's call. The refusal comes back as `aws-result` `refused` with the next step named, which is the platform path. A refusal is an outcome and not a failure. + +9. Fill the table. Copy the eight parts from [the propose loop](../../propose-loop.md) and write the desk's column yourself from what you just watched. Then put Mend's column beside it, which that page already has. + + Seven of the eight are the same shape. Target, read, operator, plan, verify, rules, record. The one that differs is **propose**. Mend's propose step is your browser holding your token, so the operator holds nothing that can write and the rules live in the thing that writes. The desk's propose step is a message, and the desk holds the credential, so the rule that matters is the scope of what that credential can reach. + + Write one sentence saying which you would rather hand to somebody else's org, and why. The IAM course answers it one way and the answer is not the same for a repo you own. ## Self-paced -{{< todo "What Floci or your own machine can and cannot show." >}} +Everything runs on the Start-here stack against Floci. Two things Floci cannot show. + +Access Analyzer is a stub there, so `proofs` prints one named skip and no proof runs. On a real account the same script returns `validate-policy` findings and a `CheckNoNewAccess` verdict, and a FAIL on a change the requester asked for is not an error, it is the thing the reviewer is being asked to approve. The lesson records one real-account run rather than pretending the emulator ran it. + +The desk's credential here is Floci's throwaway pair. On a real account it is an assume-role into `desk-operator`, bounded by the estate boundary, with the conversation id as the STS source identity, so CloudTrail says which conversation applied what. The blast radius you are looking at on this stack is the emulator, not a boundary. ## Live -{{< todo "What the room sees. Timing. The line to say." >}} +Twenty five minutes, and step 6 is the one the room remembers. Run steps 1 to 4 quickly, then delete the policy in front of everybody and say what you are doing. Approve the stale plan and let the refusal land in silence. + +Say this when it refuses. + +Nobody wrote a rule that says refuse a stale plan. The desk computes a digest, the estate moved, the digests disagree, and the only honest thing left is to stop. That is what verify before propose means when the target is alive. ## Further reading -- [The propose loop](../../propose-loop.md) -- Mend README -- dns-desk README -- Decisions 14, 15, 18 and 30 +- [The propose loop](../../propose-loop.md), the eight parts and the four forms +- [The AWS desk](../../docs/aws-desk.md), the design the desk is v0 of +- Mend README, for the browser as the propose step +- dns-desk README, for the same loop on a Cloudflare zone diff --git a/content/status.md b/content/status.md index 143a74e..a3914c4 100644 --- a/content/status.md +++ b/content/status.md @@ -36,6 +36,7 @@ not built by this workflow. | 2026-09-05 | IAM lessons 1 to 5 written against `checkpoint/i0` to `checkpoint/i5` and taken end to end by an agent playing the student from a fresh clone with only the pages, the skills and the tags. Lessons 3, 4 and 5 passed their done-when as written, 1 and 2 failed on a checkpoint compare that could not be silent. Nineteen stumbles filed and fixed, the changed steps re-run clean. | | 2026-09-05 | IAM lessons 6 to 8 written against `checkpoint/i5` to `checkpoint/i8` and taken end to end by an agent playing the student from a fresh clone. All three done-whens passed as written and every promised compare was silent. Sixteen stumbles filed, fifteen fixed in text, one left as a script finding. The access workflow's PR job and apply job each ran for real on PR 71 and its merge, the apply job matching the approved plan digest and applying. | | 2026-09-06 | Fountain lesson 3, the egress allowlist, written and taken end to end by an agent playing the student from a fresh clone. The done-when passed as written. The class stack's runner holds no egress policy, so Fountain refuses a `limited` environment rather than running it open, and the lesson teaches that refusal. It needs no inference key, since the refusal comes before the model is called. | +| 2026-09-11 | Fountain lesson 8, the propose loop interactive, written and taken end to end by an agent playing the student from a fresh clone, seven model turns on the class stack. All five done-when clauses passed as written. The desk planned the estate the repo declares at `sha256:6778be0e`, which is the fourth machine to compute that digest, applied it on `APPROVE` and the account read back by hand held the four roles and six policies the delta named. A grant asked for in plain words came back as a diff with the requester's own reason carried into the file. A policy deleted in the account between the plan and the approval made the desk refuse its own plan as `stale`, naming the grant that had gone and applying nothing, and a re-plan restored it alongside the requested grant. A boundary change came back refused. The student's parts table named propose as the one part that differs from Mend and answered the closing question against the desk, on the grounds that the stale refusal is the desk stopping itself rather than something stopping it. Five stumbles filed. The two that mattered were the skill's terminal path, which said how to send a message to the desk and never how to read the reply or where to find the agent id, both now written out. One run reported seventeen resources added for an eighteen resource plan while the estate came out correct, so both texts now say to read the account rather than the desk's sentence, and the prompt says to apply once and to keep `detail` to what the commands printed. | | 2026-09-11 | Fountain lesson 7, driving an agent from an app, written and taken end to end twice by an agent playing the student from a fresh clone, one model turn each. The lesson has a browser in it and an agent has none, so three of the four done-when clauses were checked in their terminal half and the fourth, the key named `oauth:aws-desk` that only the sign-in button produces, is unchecked and says so here. Both preflights, the desk's three objects and its seat, the `aws-state` block in the conversation and the rename that `just desk-check` refuses all passed as written. The first run found ten stumbles. Two were real bugs. Fountain scrubs every environment value of eight bytes or more out of the record, plain `env_vars` as well as secrets, so the block came back carrying `[REDACTED]` where its region and endpoint had been, and the block no longer carries either, which [project/upstream.md](https://github.com/INTENTIUS/waterpark/blob/main/project/upstream.md) records. And the page claimed an empty account reads `complete` false, which was only true while the `mktemp` bug was live, where an empty account read cleanly is `complete` true with an empty list. One was the author building a Docker image during the run, which filled the VM disk and made a sandbox fail `terraform init` and hang. The second run found five more, the load-bearing one being that the no-browser path needed an agent id nothing printed, so `just desk-hire` now prints the agent beside the conversation. The second round of fixes was verified by hand rather than by a third run. | | 2026-09-11 | The AWS desk v0, direct mode against the class stack's Floci, taken end to end. The desk read an empty account, planned the estate the repo declares, applied it on `APPROVE plan-d263` after its recheck digest matched, then took a request in plain words, edited one grant into one file, planned it, and applied it on approval. The account was read back by hand afterwards and holds four roles, two buckets, the boundary and six grant policies. The digest over the bootstrap plan is `sha256:6778be0e...` on the author's laptop, in a hand probe inside the runner and in the desk's own sandbox, which is three machines and one digest. A request to widen the estate boundary came back refused with the platform path named. Four things found and fixed, all in this repo. Six of the access scripts called `mktemp -t` with no X's in the template, which is a macOS spelling that a Linux sandbox rejects, so `access-review` died and the desk diagnosed the bug itself before falling back to direct account reads. The class runner image carried no `terraform` and no `tflint`. A sandbox runs with its own directory as `HOME`, so a lint plugin installed under `/home/runner` is one nobody finds. And the page's block parser required a fence to start its own line, which the joined reply text does not always give it, recorded in [project/upstream.md](https://github.com/INTENTIUS/waterpark/blob/main/project/upstream.md). | | 2026-09-10 | IAM lesson 14, approve the change not the diff, written against `checkpoint/i15` to `checkpoint/i14` and taken end to end by an agent playing the student from a fresh clone, on the class stack's Floci and a second container. All five done-when clauses passed as written. The saved plan's delta and digest read as what a reviewer approves, a hand edit to the account changes the digest with the diff naming why, Terraform refuses a stale saved plan on its own, one in-place change planned from two emulators gives one digest where the digest from before lesson 11's fix gave two, and a rename renders a replacements section the PR job now labels. Five stumbles filed, two the saved plans and the step 3 grant reaching the compare, three wording, all fixed. | @@ -52,8 +53,8 @@ not built by this workflow. ## Written, not yet verified -- 4 of the 26 lesson bodies are skeletons with TODO markers. Written - and student-run: Fountain lessons 1 to 7, 10 and 11, and IAM lessons 1 to 11, 14 and 15. +- 3 of the 26 lesson bodies are skeletons with TODO markers. Written + and student-run: Fountain lessons 1 to 8, 10 and 11, and IAM lessons 1 to 11, 14 and 15. `just todos` counts 41 today. - The access repo exists under `access/` for what IAM lessons 1 to 8 need (layout, personas, the check stack, the Floci deploy, the diff --git a/desk/PROMPT.md b/desk/PROMPT.md index 5af7bf6..51c21f0 100644 --- a/desk/PROMPT.md +++ b/desk/PROMPT.md @@ -126,9 +126,18 @@ cd estate/access/envs/"$DESK_WORKSPACE" terraform apply -input=false .desk/"$PLAN_ID"/tfplan ``` +Apply once. If you are unsure whether the apply ran, read the account and say +what you find. Never run it a second time to see what happens, because the +second run is either a no-op you did not need or a refusal you then have to +explain, and neither is a thing the person asked for. + Emit `aws-result` with status `applied`. Terraform refuses a saved plan whose state has moved on its own, so your digest check and the applier agree. +`detail` is yours to write, unlike the three fields below, so keep it to what +the commands actually printed. A count you half remember is worse than no +count, because the person reading it cannot tell which it was. + Then say the part nobody likes. The edit you applied lives on your computer and nowhere else. The repo still declares the estate as it was, so the next person to plan from a fresh clone plans your change away. Print the diff @@ -171,7 +180,10 @@ put your prose outside the fences. {"plan_id":"plan-7f3a","status":"applied","detail":"Apply complete. 2 added, 0 changed, 0 destroyed."} ``` -`status` is one of `applied`, `stale`, `refused` or `failed`. +`status` is one of `applied`, `stale`, `refused` or `failed`. A refusal that +never got as far as a plan carries `"plan_id": null`, because there is no +plan to name and inventing one would put a number in the record that nothing +else knows about. Never put a value your own environment holds into a block. Fountain scrubs every environment value of eight bytes or more out of what it records, which diff --git a/desk/fountain.yaml b/desk/fountain.yaml index 00d34ec..98495a6 100644 --- a/desk/fountain.yaml +++ b/desk/fountain.yaml @@ -173,9 +173,18 @@ spec: terraform apply -input=false .desk/"$PLAN_ID"/tfplan ``` + Apply once. If you are unsure whether the apply ran, read the account and say + what you find. Never run it a second time to see what happens, because the + second run is either a no-op you did not need or a refusal you then have to + explain, and neither is a thing the person asked for. + Emit `aws-result` with status `applied`. Terraform refuses a saved plan whose state has moved on its own, so your digest check and the applier agree. + `detail` is yours to write, unlike the three fields below, so keep it to what + the commands actually printed. A count you half remember is worse than no + count, because the person reading it cannot tell which it was. + Then say the part nobody likes. The edit you applied lives on your computer and nowhere else. The repo still declares the estate as it was, so the next person to plan from a fresh clone plans your change away. Print the diff @@ -218,7 +227,10 @@ spec: {"plan_id":"plan-7f3a","status":"applied","detail":"Apply complete. 2 added, 0 changed, 0 destroyed."} ``` - `status` is one of `applied`, `stale`, `refused` or `failed`. + `status` is one of `applied`, `stale`, `refused` or `failed`. A refusal that + never got as far as a plan carries `"plan_id": null`, because there is no + plan to name and inventing one would put a number in the record that nothing + else knows about. Never put a value your own environment holds into a block. Fountain scrubs every environment value of eight bytes or more out of what it records, which diff --git a/skills/README.md b/skills/README.md index a421d92..e9ce1f5 100644 --- a/skills/README.md +++ b/skills/README.md @@ -88,6 +88,7 @@ that it is `student run`. | `f5-the-team` | Fountain lesson 5, The team. Adds an agent to the team with `curl`, keeps the team stream open, reads the roster and the channel-filtered conversation list as two views of one conversation, messages the teammate and gets a 400 for a second message mid-turn, adds the same agent again for a 200, lets it sleep and wakes it, stops the runner to read `machine_offline` and a queued message, and removes it to find the binding gone and the record kept. Three model turns. Reuses `skills/start/check.sh`. | student run | | `f6-schedules` | Fountain lesson 6, Schedules. Gets `@reboot` refused, makes a disabled `@daily` schedule before the teammate exists and reads the `agent is not on the team` stamp a run leaves, adds the agent to the team, creates an every-minute schedule and catches the fire on the stream with `last_conversation_id` equal to the thread, disables it and runs it by hand, runs a one-off on a fresh sandbox with no channel, and removes the teammate to find its schedules gone. Three model turns. Reuses `skills/start/check.sh`. | student run | | `f7-driving-an-agent-from-an-app` | Fountain lesson 7, Driving an agent from an app. Proves what `API_CORS_ORIGINS` admits with a preflight from the page's origin and one from an origin nobody named, applies the AWS desk's three objects from `desk/fountain.yaml` and puts it on the team with its vault bound, has the student sign in from the page and verifies the key it got lists as `oauth:aws-desk`, asks the desk what the estate holds and finds the fenced block in the conversation rather than on the screen, then renames the block on the page's half of the protocol and lets `just desk-check` refuse it. Has a browser in it, so the seeing is the student's and the verifying is the agent's. One model turn. Reuses `skills/start/check.sh`. | student run | +| `f8-propose-loop-interactive` | Fountain lesson 8, The propose loop interactive. Runs the whole loop with the desk from lesson 7. Plans the estate the repo declares and applies it on `APPROVE`, reads the account back by hand rather than trusting the reply, takes one grant request in plain words and reads the diff the desk made, deletes a grant in the account before approving so the plan comes back `stale` with the moved resource named and nothing applied, recovers with a re-plan, gets a boundary change refused, and ends by filling the eight parts for the desk against Mend and naming propose as the one that differs. Seven model turns. Reuses `skills/start/check.sh`. | student run | | `f10-self-hosted-runner` | Fountain lesson 10, The self-hosted runner. Reads the runner from `GET /api/runners`, from `docker compose ps` and from inside its container as one process and one user holding the API key, replays the `limited` refusal, has a second sandbox list its sibling and reads both directories as one owner from the runner's shell, stops the container for a `409` on a new run and a `runner_offline` failure on a parked one, restarts it and prompts the same conversation, and fills the trade table. Three model turns. Reuses `skills/start/check.sh`. | student run | | `f11-no-gate-in-fountain` | Fountain lesson 11, No approval gate in Fountain. Has an agent write a file with nobody asked, reads the runtime's command line off the runner mid-turn for `--allow-dangerously-skip-permissions`, pages the conversation's events and counts zero `request_permission` messages beside the `Write` tool call, asks for a deletion and hears the model decline on its own judgment, reads ADR 0016's status, and fills the gate table ending on `nothing` for the sandbox. Two model turns. Reuses `skills/start/check.sh`. | student run | | `i1-one-type-per-file` | IAM lesson 1, One resource per file. Worktree at `checkpoint/i0`, builds `access/envs/prod` one resource block per file, `terraform fmt`, `init` and `validate`, then breaks the convention and shows Terraform accepting it. No Floci, no AWS. | student run | diff --git a/skills/f8-propose-loop-interactive/SKILL.md b/skills/f8-propose-loop-interactive/SKILL.md new file mode 100644 index 0000000..6990169 --- /dev/null +++ b/skills/f8-propose-loop-interactive/SKILL.md @@ -0,0 +1,262 @@ +--- +name: waterpark-f8-propose-loop-interactive +description: Walk a student through Fountain lesson 8, The propose loop interactive. Use when they want lesson 8, or when they ask what the propose loop is, what an access delta is, or why an agent should refuse its own plan. Runs the desk's loop end to end against Floci, plans the estate and applies it on approval, takes a request in plain words, moves the account by hand so an approved plan comes back stale, recovers, gets a boundary change refused, and ends by filling the eight parts for the desk against Mend. Seven model turns. +--- + +# water park, Fountain lesson 8, The propose loop interactive + +You are walking a student through Fountain lesson 8, The propose loop +interactive (https://intentius.io/waterpark/courses/fountain/08-propose-loop-interactive/). +The outcome is the whole loop run once against a real account, including the +part where the agent refuses its own plan. About an hour. + +This lesson needs the desk from lesson 7 already on the team. If it is not, +stop and send them to lesson 7. `just desk-hire` on its own will not do, +because they will not know what they are looking at. + +Confirm before each `APPROVE` and before the hand edit in step 5. Those are +marked **confirm**. Reads run freely, which is every `GET` and every +`aws ... list-*` or `get-*`. + +This lesson makes seven model turns and each one can take minutes. Say so +before the first. If the student is short on inference budget, steps 1 to 3 +and 9 are the smallest honest subset and cost three turns. + +The student may drive the desk from the page at http://localhost:1313/desk/ +or from a terminal. The page adds nothing to this lesson except that the +Approve button sends the same sentence they would type. + +From a terminal it is three calls and they are all below. Lesson 7 hired the +desk, so the ids are on the roster rather than in anybody's scrollback, and +re-running `just desk-hire` to find them is not the way. + +```sh +KEY=$(grep '^FOUNTAIN_API_KEY=' compose/.env | cut -d= -f2-) +AGENT=$(curl -s -H "Authorization: Bearer $KEY" http://localhost:4000/api/team | + jq -r '.data[]|select(.agent.name=="aws-desk")|.agent_id') +CONV=$(curl -s -H "Authorization: Bearer $KEY" http://localhost:4000/api/team | + jq -r '.data[]|select(.agent.name=="aws-desk")|.conversation.id') +``` + +Send a message. + +```sh +curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg p 'the words' '{prompt:$p}')" \ + "http://localhost:4000/api/team/$AGENT/messages" +``` + +Wait for it. A message is queued and the turn takes ten to twenty seconds to +appear, so a loop that waits for "nothing running" returns at once and looks +like a finished turn. Wait for the turn count to go up first, then for the +status to come back to `idle`. + +```sh +curl -s -H "Authorization: Bearer $KEY" "http://localhost:4000/api/conversations/$CONV/turns" | jq '.data|length' +curl -s -H "Authorization: Bearer $KEY" "http://localhost:4000/api/conversations/$CONV" | jq -r '.data.status' +``` + +Read the reply. The blocks are in the log feed, already parsed, and the text +of a turn is its `text` blocks joined in order. + +```sh +curl -s -H "Authorization: Bearer $KEY" \ + "http://localhost:4000/api/conversations/$CONV/events?blocks=true&streams=acp&limit=1000" -o /tmp/ev.json +jq -r '[.data[].blocks[]?|select(.kind=="text")|.body]|join("")' /tmp/ev.json | tail -c 3000 +``` + +Two things about that last one. Write the response to a file rather than a +shell variable, because a variable holding JSON and echoed back into `jq` is +a parse error waiting to happen. And if `meta.has_more` is `true`, pass +`meta.next_cursor` back as `&after=` and join the pages. + +## 1. Say what this is + +In two or three sentences say this is lesson 8 of the Fountain course. The +propose loop has eight parts and the desk is one instance of it. The part +worth waiting for is verify, which here means the desk re-plans on approval +and refuses its own plan when the estate has moved. Link the lesson page. + +## 2. Check the ground + +```sh +bash skills/start/check.sh +``` + +Then check the desk is on the team and the account is readable. + +```sh +just status +export AWS_ENDPOINT_URL=http://localhost:4566 AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1 +aws iam list-roles --query 'Roles[].RoleName' --output text +``` + +Keep that shell for the whole lesson. Every read below is a bare `aws` call +and it needs those four variables, so a second terminal or a fresh shell +reads a real account instead of the emulator. + +An empty answer is the right starting point and the lesson watches it fill. +If roles are already there, the estate has been applied before. That is fine. +Say so, and expect the plan in step 3 to be smaller or empty. + +## 3. Plan the estate the repo declares + +Ask the desk, in the page or over the API. + +``` +Plan the first apply of everything the repo declares. +``` + +It reads the account first, then plans. When the `aws-plan` block lands, read +it to the student in the order the block puts it, because that order is the +lesson. The access delta first, grouped by principal. Then the proofs, which +on Floci are one named skip. Then the diff, which is empty here because no +file was edited. Then the typed changes. + +Say the thing the block is for. Eighteen resources is a Terraform fact. Four +principals, five grants and a boundary is what somebody is being asked to +approve, and `render-delta` wrote that sentence, not the model. + +Note the plan id. It looks like `plan-f97b`. + +## 4. Approve it **confirm** + +``` +APPROVE plan-xxxx +``` + +Nothing else approves. Not "looks good". The desk plans again, compares the +new digest to the one it showed, and applies the plan that was approved. + +Then read the account back yourself, because the live system is the truth and +the desk saying it applied is not the account saying so. + +```sh +aws iam list-roles --query 'Roles[].RoleName' --output text +aws iam list-policies --scope Local --query 'Policies[].PolicyName' --output text +``` + +Four roles and six policies is the shape to expect. + +Check the account rather than the sentence. `detail` in an `aws-result` is +the desk's own words, unlike the delta and the digest, so a count in it is +the model reporting and not a script. One run said "17 added" for an +eighteen resource plan and the estate was correct anyway. The account is what +settles it. + +## 5. Ask for one grant + +``` +site-publisher needs list on waterpark-artifacts, so the build can see which checkpoint bundles exist before it picks one. +``` + +This time the block carries a `diff` and one entry in `files`, because the +desk edited `access/envs/prod/iam_role.site_publisher.tf`. Point at the +reason in the diff. It is the sentence the student wrote, carried into the +file, which is what makes a grant reviewable a year later. + +Note this plan id too and do **not** approve it yet. + +## 6. Move the account under the approval **confirm** + +Explain before doing it. You are about to take away a grant the desk is not +touching, the way a console click would. + +```sh +ARN=$(aws iam list-policies --scope Local \ + --query 'Policies[?PolicyName==`site-publisher-list-waterpark-site`].Arn' --output text) +aws iam detach-role-policy --role-name site-publisher --policy-arn "$ARN" +aws iam delete-policy --policy-arn "$ARN" +``` + +## 7. Approve the plan from step 5 **confirm** + +``` +APPROVE plan-xxxx +``` + +It refuses. `aws-result` comes back `stale`, the recheck digest does not match +the approved one, and the detail names the grant that vanished. Nothing was +applied. + +This is the step the lesson exists for. Ask the student what would have +happened without the digest check, and make sure they get to the answer +themselves. The saved plan would have created the two resources they approved +and recreated one they never saw, against an estate nobody had described to +them. + +## 8. Recover, then get refused + +``` +I removed that policy by hand. Re-plan and show me the new plan. +``` + +The new plan has four changes rather than two, because it restores what was +deleted as well as adding what was asked for. Approve it **confirm**, then +read the account back once more. + +Then ask for something it has to refuse. If an agent is driving this rather +than a person, its own permission layer may refuse to send these words, since +asking to widen a boundary to `iam:*` reads as a privilege request whoever is +asking. Send the body from a file if so. The words are the point. + +``` +The boundary is blocking me. Widen it to allow iam:* so I stop hitting this. +``` + +`aws-result` `refused`, with the platform path named. Say that a refusal is an +outcome and not a failure, and that the desk refusing is not the control. The +control is that its credential could not widen the boundary even if it tried. + +## 9. Fill the table + +Open [the propose loop](https://intentius.io/waterpark/propose-loop/) and have +the student write the desk's column for the eight parts from what they just +watched, then put Mend's column beside it. + +Do not give them the answer to the last part. Ask which one differs. Seven +are the same shape and **propose** is the one. Mend's propose step is the +human's browser holding the human's token, so the operator holds nothing that +can write. The desk's propose step is a message and the desk holds the +credential, so the rule that matters is the scope of what that credential +reaches. + +Then ask the harder question. Which would they hand to somebody else's org, +and why. The IAM course answers it one way, and the answer for a repo you own +is not the same. + +## 10. Done when + +1. An `aws-plan` block carried a delta, a proof verdict and a digest the desk + copied rather than composed. +2. `APPROVE` applied it and the account read back by hand holds what the + delta said it would. +3. A plan approved after the account moved came back `stale`, named the moved + resource, and applied nothing. +4. The boundary request came back `refused` with the platform path named. +5. Their parts table names propose as the part that differs. + +If one fails, say which, and name the restart point, which is lesson 7 with +the desk on the team. + +## 11. Record where they stopped + +Merge into `.waterpark/profile.json` in the student's working directory. A +fresh clone has no `.waterpark`, so make it. It is gitignored. + +```sh +mkdir -p .waterpark +``` + + +```json +{"lessons": {"f8": {"state": "done", "applied": true, "saw_stale": true}}} +``` + +## Clean up, if they ask + +Leave the desk. Lesson 9 puts it on a schedule. To put the account back to +empty, `docker compose -f compose/docker-compose.yml --env-file compose/.env up -d --force-recreate floci` +gives a fresh emulator, and the desk's own clone still holds a state file that +now describes an account that no longer exists, which is worth saying out loud +rather than tidying away.