Build a licensed content distribution prototype for EdTech with Kiro
Learn about a prototype to connect account records and access policies to improve the operation of the licensed education content that you sell.
Introduction
If you sell licensed educational content to schools and districts, then you probably run two systems. One tracks the customer information, such as the plan, seat count, and renewal date. The other distributes the material. The systems likely don’t interact. So, when a renewal comes round, nobody can say precisely what was sent, to whom, or under what terms.
A general-purpose customer relationship management (CRM) system doesn’t close that gap. It holds the account record, but not the access policy that has to travel with a delivery.
To fill this gap, we built a prototype that holds both account records and access policies, using spec-driven development in the Kiro IDE. This article is for developers or small engineering teams who want to build and maintain a similar tool. The article covers what we built, and more usefully, how the spec workflow changed how we built it.
What we built
Our prototype consists of a single web application with the following three workspaces and one background job:
-
The Accounts workspace lists school districts with their lifecycle stage, plan, seat count, recurring revenue, and renewal date. This workspace lets an account manager log support time against a district.
-
The Library workspace is a catalog of curriculum documents that staff can browse, search, filter, and sort.
-
The Distribute workspace is where a curriculum lead picks a document and writes an access policy for it.
-
Behind the workspaces, the background job prepares and sends deliveries.
Example: the prototype in action
An account manager opens a district, sees that it’s up for renewal next quarter, and logs an hour of support time (Figure 1). A curriculum lead then picks a document, sets an expiration date at the end of the school year, allows printing, and turns on a visible watermark. The lead names two district contacts as recipients and starts the delivery (Figure 2). Each recipient receives one email with a download link that stops working on the expiration date and one email with the file’s password. The delivery appears in the district’s history so that the terms that were granted become part of a record. At renewal time, no one needs to recall the terms.
Figure 1: The Accounts workspace. Tiles summarize pipeline value and the revenue tied to accounts that renew next quarter. The side panel shows one district and its delivery history.
Figure 2: Authoring an access policy before a delivery. Each field is labelled according to whether the system enforces it or only records it.
Figure 3: The library workspace, with the search, filter, and sort controls.
What spec-driven development changed
Ask an agent to build something and you get code that runs. What you don’t necessarily get is code that another engineer can take over. Decisions for the original code were made inside a conversation that nobody reopens. The ambiguities were resolved, but silently, and only the original builder knows the reasoning.
Kiro reorders the work. Before any code exists, a spec session produces the following three documents that you must approve:
- Requirements: user stories with acceptance criteria that names a trigger, the part of the system responsible, and an observable result.
- Design: the architecture, the data model, and a set of correctness properties that are statements that must be true for every input rather than for one example.
- Tasks: ordered plans in which each task cites the requirement that it satisfies.
The whole Library workspace started from the following prompt that named intent and constraints and mentioned no files and no libraries:
Create a spec for a selectable library of educational documents. Users browse, search, filter, and sort a curated collection. Include categorization, metadata display, and document selection. Sorting should be able to prioritize documents by regional relevance and language affinity.
What came back were 12 requirements, 17 correctness properties, 55 tasks, and no application code yet.
This article focuses on the Library workspace, which came first. The district records, the account data, and the rest of the business side were built on top of the library in later sessions.
Figure 4: A Kiro spec session, from the opening prompt through to implementation.
You move from prompt, to requirements, to design, to tasks, to implementation one task at a time. Importantly, you then loop back from implementation to requirements. When writing the code exposes a gap, you can change the requirement and let the design and the task plan regenerate, rather than patching the code and abandoning the spec.
Requirements that turn into tests
We wrote the following requirement for search:
Requirement 2: Search Documents. As a user, I want to search for documents by keyword so that I can quickly find specific materials.
Acceptance criteria: WHEN the user enters a search term, THE Search_Engine SHALL return documents whose title or description contains the term. WHEN the search term is empty, THE Search_Engine SHALL return the full catalog.
The phrasing uses a convention called EARS (Easy Approach to Requirements Syntax). The benefit of EARS is that it leaves nothing to reinterpret. The design document restates the same rule as a property. For any catalog and any search term, the result contains exactly the documents with a title or description that contains the term. Kiro then generated a test from the property that runs against hundreds of randomly generated catalogs and terms on each commit.
The acceptance criterion, the property, and the test are three phrasings of one sentence. A reader can check each against the others.
Example
The claim that requirements become tests is easier to trust when you can see the chain. Here’s how it worked for search in the three places that it appears.
The following is the acceptance criteria in requirements.md:
### Requirement 2: Search Documents
**User Story:** As a user, I want to search for documents by keyword,
so that I can quickly find specific materials.
#### Acceptance Criteria
1. WHEN the user enters a search term, THE Search_Engine SHALL return
documents whose title or description contains the term
2. WHEN the search term is empty, THE Search_Engine SHALL return the
full catalog
The following is the same rule in design.md, restated as a property that acts as a statement about each possible input rather than one example:
**Property (search is exact):** For any catalog C and any non-empty term t,
search(C, t) contains exactly those documents d in C where
lower(d.title + " " + d.description) contains lower(t).
Finally, the following is the test that Kiro generated from the property:
// tests/catalog/search.property.test.ts
import fc from "fast-check";
import { search } from "../../src/catalog/search";
import { arbDocument } from "../arbitraries";
test("search returns exactly the documents whose title or description contains the term", () => {
fc.assert(
fc.property(fc.array(arbDocument()), fc.string({ minLength: 1 }), (docs, term) => {
const matches = (d: Document) =>
`${d.title} ${d.description}`.toLowerCase().includes(term.toLowerCase());
const result = search(docs, term);
expect(result.every(matches)).toBe(true);
expect(result.length).toBe(docs.filter(matches).length);
})
);
});
Note: The test constructs random catalogs and random search terms. It asserts both halves of the property. Every result matches and no match was left out.
The benefit? Nobody had to decide what “search” was supposed to mean. The three versions of the same sentence are each checkable against the others.
Decisions and what replaced them
The design document records each architectural choice as it’s made. Two of the earliest decisions in the development process were that everything ran in the browser, and the browser’s own local storage held the data. Neither decision survived to the final design. Local storage can’t serve a shared catalog to several staff members. It also can’t hold a delivery record that anyone can audit. A later session moved the catalog to a managed database and the files to object storage.
The rejected decisions stayed in the document, with a note that described what replaced them. Because the design document records the replacement next to the original rather than edits out the original, it keeps the reasoning legible 6 months later. A reader can see why the first choice was wrong instead of wondering whether it was ever considered.
A plan that cites its requirements
Each task in the plan names the requirement that it serves, making the plan auditable. When a reviewer asked why the sample catalog needed 20 documents, the answer was a lookup rather than an argument.
This methodology also changed how we prompted for implementation. Instead of asking for features, we asked for tasks, such as “implement tasks 2.1 and 2.2, run their tests, and stop.”
How the application comes together
Figure 5: The application architecture.
Staff sign in through a managed identity service. Their role defines what they can access. Account managers get the district records and the delivery history, while curriculum leads get the catalog and the ability to start a delivery. Staff members hold only temporary credentials, and no credentials are shipped to the browser. This is important because the Accounts workspace contains contract terms and revenue, and the library contains licensed material. This is data that a district reasonably expects to sit behind a login, and in many jurisdictions, has security and management requirements.
The catalog and the account records live in a managed database. We designed the catalog for up to 10,000 documents for each tenant. Currently, the app loads the whole catalog into the browser and searches, filters, and sorts from there. This configuration works well at this size, with search results typically completing within 500 ms. For a larger catalog, you need to add server-side search and pagination.
Sometimes, the obvious shortcut is wrong. For example, the following catalog behaviors work differently than we originally planned:
-
A catalog entry that fails validation isn’t quietly discarded. Instead, the library renders what it can and tells staff how many entries it skipped. A silent drop hides a data problem instead of surfacing it.
-
If the read itself fails, then the library shows an error and a retry button instead of falling back to bundled sample data. An account manager should never be looking at invented records without knowing it.
When a curriculum lead starts a delivery, the browser puts the file and the policy into a private staging area in object storage. This launches the background job. The job encrypts the document with a password that's unique to the recipient and delivery, and adds a watermark. The job also saves the protected copy under a timestamped path so that a past delivery is still findable at renewal. Then, the job writes the delivery record and emails the recipient. The link and the password are sent in separate messages. An internal notification notifies staff that the delivery completed. This notification doesn’t have a password.
Encryption runs in the background job rather than in the browser, but not because a browser is incapable of encryption. It’s because the unprotected file shouldn’t exist on staff member’s laptop any longer than it has to. Also, the document’s permissions are governed by a different password from the one that opens the document, which shouldn’t reach a browser or leave the system.
We decided the failure behavior in the design before we wrote any code, making it part of the requirements. If the notification fails after the file is already prepared and filed, then the system doesn’t roll back delivery. In this scenario, the artifact exists and is usable, so the system records the failure and retries the email. However, if the encryption fails, then everything stops. Nothing is filed, no record is written, and no email goes out. An encryption failure mustn't cause the system to distribute an unprotected file.
Also, a content pack of 30 articles produces one protected bundle with one password for each recipient, not 30 passwords. The alternative sends the district a spreadsheet of credentials and then a second spreadsheet at renewal. This situation results in one shared document that circulates among staff and undoes the entire recipient-level access concept.
What’s enforced and what’s only recorded
The fields on the policy form are either enforced or recorded. Enforced fields directly affect the file. Recorded fields don’t affect the file, but do get their values stored in the system. The policy form clearly distinguishes which fields are enforced and which are recorded.
Enforced
The download stops working on the expiration date. Only the named recipients receive the link and the password. You can set whether printing is allowed as a permission on the document itself, under a second password that a recipient never receives. The watermark is stamped onto every page.
Recorded, but not enforced
The system stores the country restrictions and a limit on the number of prints and devices for recipients in the delivery record for audit purposes. A standard PDF reader doesn’t count the number of prints or devices. Enforcing this requirement requires a viewer that checks a license server when someone opens the document.
Even though it isn't enforced, the prototype keeps records of the requirement and what doesn’t meet it. At renewal, an account manager can see exactly which terms were granted to whom.
What the prompts taught us
The shape of a prompt mattered more than its wording. Prompts that named a property produced architecture that lasted. Prompts that named a technique produced code that we replaced within days.
| Prompt shape | What it produced | Result |
|---|---|---|
| Name the failure mode | “A notification failure must not roll back a completed upload. An encryption failure must never fall through to distributing an unprotected file.” | Became two tests and survived three refactors. As a code comment, it wouldn’t last. |
| Ask for analysis, not action | “List the findings, ranked, and change nothing.” | Four rounds surfaced a type that validated nothing, a test that duplicated the build, and a validation path that can't run. The list became the work queue. |
| Question the fit | “Is a card grid the right interaction model for an account manager?” | A dense table replaced the card-only catalog. A feature request couldn't surface it; the question did. |
| Name the property, not the technique | “Heavy clients must not load until the workspace that needs them opens.” | Produced the correct structure on the first attempt because we stated the requirement as a property of the result not an instruction about how to achieve it. |
The pattern is consistent enough that you can adopt it. Describe the outcome you need and the failure you can’t tolerate. Don't change the method.
Conclusion
Building rather than buying helps keep your system maintainable. What made this prototype maintainable wasn’t the code, but the material around the code. We phrased requirements so that they became tests. We recorded decisions next to the ones they replaced with a task plan that cited both. 6 months from now, the question “Why does it work this way?” has a written answer.
For more information, see Specs.
- Language
- English

Relevant content
AWS OFFICIALUpdated a year ago
AWS OFFICIALUpdated 2 years ago
AWS OFFICIALUpdated 10 months ago