~/satyajit

gdp-ts: making the authorization check a value the compiler can see

mdjsonmcp

2026-10-06 · 18 min · explainer · agents · security · harness · open-source

Most authorization bugs are not wrong policy. They are a correct check that never reached the line that needed it: the handler checks project A and writes to project B, or a refactor drops an await. Code review is supposed to catch these, and at the rate agents now write code, review is the part that does not scale.

gdp-ts is Guillermo Rauch's answer, announced on X on 2026-10-05 as "a library, linter and AI skill for safer API design". Under its contract, "sensitive functions require 'proofs' that the caller performed an authorization check", and the type checker verifies them. The pattern is old. It is Matt Noonan's Ghosts of Departed Proofs from Haskell Symposium 2018, applied to authorization by Ollie Charles in 2019. Rauch's argument is about why it is practical now:

Agents are writing more code than we can review, and they thrive in tight loops with hard constraints that would frustrate us.

I read the whole repository, re-derived the mechanism, and type-checked every combination of mistakes a caller can make in front of the README's own sensitive function. The type checker catches all the honest mistakes. The two forgeries that get past it are what the linter is for.

A pixel-art ghost holding a document with a TypeScript badge and a green check mark, above the words Ghosts of Departed Proofs for TypeScript.
The project's mascot: a ghost carrying a checked proof. A frame from the launch video (Guillermo Rauch's announcement post on X, 2026-10-05).
Repositoryrauchg/gdp-ts, MIT, package @gdp-ts/core 0.1.0
Commit readebd0af9, 2026-10-05
Librarysrc/index.ts: 168 lines, 42 of them code (measured)
Lintersrc/lint/plugin.ts: 273 lines, 5 rules, presets for ESLint and Oxlint (measured)
Skillskills/gdp-ts/SKILL.md plus five reference files
RequiresTypeScript 5.4 or newer

The bug is a boolean

The README opens with this handler:

async function handler(req, res) {
  await assertProjectAdmin(req.user.id, req.params.projectId); // throws 403
  await setPasswordProtection(req.params.projectId, req.body.password); // nothing connects this to the line above
  res.sendStatus(204);
}

The comment on the second line is the whole problem. assertProjectAdmin learns something true about one user and one project, then throws the knowledge away. What reaches setPasswordProtection is a project id and a password, and its signature, (projectId: ProjectId, password: string), accepts any caller that has those, checked or not. Robert Harper called this boolean blindness: a test yields one bit, and the bit does not say what it was about.

The fix is to make the check return something that does say what it was about, and to make the sensitive function demand it.

Ghosts, in Haskell

Noonan's paper starts from a smaller problem. A mergeBy over two lists is only correct if both were sorted by the same comparator. Documenting that, checking it at runtime, or returning Maybe all push work onto a caller who already knows the answer. GDP lets the caller tell the library.

Step one is a name. A value of type a gets a phantom type parameter name, which exists only to the compiler:

Haskell code: module Named exports Named, the ~~ operator and name. newtype Named name a = Named a. name has the rank-2 type a -> (forall name. (a ~~ name) -> t) -> t.
Noonan's naming module. The rank-2 type of name hands the caller a value under a name it did not choose and cannot write down, and hiding the Named constructor makes name the only way to introduce one (Noonan, Ghosts of Departed Proofs, Figure 2).

The forall name. inside the argument is the trick. name takes a continuation that must work for every possible name, so inside it the name is a fresh, opaque type that matches nothing else. Two calls give two incompatible names, even for the same value.

Step two is a predicate attached to a name, and a module that is the only place able to create it:

Haskell code: module Sorted exports SortedBy, sortBy and mergeBy but not the SortedBy constructor. sortBy takes a comparator named comp and returns SortedBy comp [a]; mergeBy requires two lists that are SortedBy the same comp.
sortBy is the only way to get a SortedBy comp list, and mergeBy demands two lists sorted by the same named comparator. Merging what you did not sort is a type error, and the coercions compile to nothing (Noonan, Ghosts of Departed Proofs, Figure 3).

That is the full pattern: name a value, let one trusted module mint facts about names, and make the consumer demand the fact. The paper's phrase for why the proofs are "ghosts" is that "no artifacts related to the proof should ever be discernible from the compiler's output".

Ollie Charles's Who Authorized These Ghosts!? made the jump to access control at CircuitHub: name the project and the user, and make price demand a CanViewProject proof about exactly those two names. gdp-ts is that post, ported to TypeScript and to Vercel's Password Protection feature.

Three moves in TypeScript

Name values

// app.ts (from the gdp-ts README)
app.delete(
  "/projects/:id/password-protection",
  authenticated((viewer, req, res) =>
    name(viewer.id, ProjectId(req.params.id), async (user, project) => {
      const admin = await userIsProjectAdmin(user, project);
      if (!admin) throw new HttpError(403, "Only Owners and Members can change this");
      await disablePasswordProtection(project, admin);
      res.sendStatus(204);
    }),
  ),
);

TypeScript has no forall inside an argument type, but it has generic callbacks, which do the same job. The library's signature for the two-value case:

// src/index.ts
export function name<A, B, R>(
  a: A,
  b: B,
  k: <N, M>(a: Named<N, A>, b: Named<M, B>) => R,
): R;

Inside k, N and M are type parameters the callback must be generic over. Nothing outside can produce a Named<N, …>, and the two are distinct from each other. At runtime name wraps each value in a frozen { value } and calls k.

Prove facts about names

A proof lives in a small module under proofs/. The module creates a prover and keeps it private:

// proofs/user-is-project-admin.ts (trusted: the only place that can mint this proof)
const UserIsProjectAdmin = defineProof("UserIsProjectAdmin"); // not exported
export interface UserIsProjectAdmin<U, P> extends Proof<"UserIsProjectAdmin", [U, P]> {}
 
export async function userIsProjectAdmin<U, P>(
  user: Named<U, UserId>,
  project: Named<P, ProjectId>,
): Promise<UserIsProjectAdmin<U, P> | null> {
  const role = await db.roleInProjectTeam(user.value, project.value);
  return role === "owner" || role === "member" ? UserIsProjectAdmin.prove(user, project) : null;
}

prove(user, project) infers the proof's About tuple from its arguments, so the proof is visibly about these names. A failed check returns null, and the caller decides whether that is a 401, a 403 or a fall-through.

Demand proofs

// data/projects.ts (sensitive: demands exact proofs)
export function disablePasswordProtection<U, P>(
  project: Named<P, ProjectId>,
  _proof: UserIsProjectAdmin<U, P>,
): Promise<void> {
  return db.writePasswordProtection(project.value, null);
}

The P in the project and the P in the proof are the same type parameter. A proof minted for a different name() call, or for a different value in the same call, carries a different name and does not unify. When a function needs several facts, it takes them as an object. The README's headline case needs both a role and an entitlement:

export function setPasswordProtection<U, P>(
  project: Named<P, ProjectId>,
  actor: Named<U, UserId>, // recorded as updatedBy; the proofs must be about this user
  setting: { deploymentType: DeploymentType; password: string },
  _proofs: { manage: CanManageProtection<U, P>; plan: PlanIncludesPasswordProtection<P> },
): Promise<void>;

CanManageProtection is the admin proof. PlanIncludesPasswordProtection is a proof about the project's billing plan, which has nothing to do with who is asking. Password Protection is not on the Hobby plan, so forgetting that check is a support ticket today and a compile error here. Taking actor as a named argument means a manage proof about some other user cannot be passed in while the audit column records you.

Where TypeScript fights back

Haskell gave Noonan three things for free that TypeScript does not. src/index.ts is mostly comments explaining how it gets each one back.

Phantom parameters are ignored unless used. TypeScript compares types by structure. An interface whose N appears in no property is the same type for every N, so every name would match every other. gdp-ts stores the name in a property that is never assigned:

// src/index.ts
export interface Named<in out N, out A> {
  readonly value: A;
  readonly [NAME]: (n: N) => N;
}

The in out annotation makes N invariant, so a Named<never, A> or Named<unknown, A> cannot stand in for a real name. The comment explains why the function-typed slot is there as well: TypeScript falls back to structural comparison in some places, notably discriminated-union targets, and a plain N slot would let never through there. Proof<Kind, About> uses the same (about: About) => About slot.

Names must not escape. If a callback could return its Named, the name would outlive the request it was checked in. Returning one forces TypeScript to instantiate N as unknown on the way out, and invariance rejects that. I checked it on 5.9.3: name(a, (project) => project) fails with Type 'Named<N, ProjectId>' is not assignable to type 'Named<unknown, ProjectId>' (measured). The skill's limits page says TypeScript 5.4 and 5.5 catch a returned Named too, but let one wrapped in an object leak as an unbound type parameter that still cannot be mixed up with another (reported).

There are no private constructors. In Haskell, not exporting SortedBy's constructor means no client can make one. In TypeScript, {} as UserIsProjectAdmin<U, P> compiles anywhere. The library confines its own cheat to one line, inside defineProof:

// The single type assertion in this library. Everything downstream of
// `prove` is honest, which is what lets you ban `as` in application code.
prove: () => proof as Proof<Kind, any>,

That comment is the design. The honest path never needs an assertion, so any assertion near a proof is either a bug or a forgery, and a linter can flag it.

Will it typecheck?

The sensitive call is setPasswordProtection(project, user, pw, { manage, plan }). A caller makes four choices in front of it: which role check to run (if any), whether to handle a null result, which project's plan to check, and whether to pass the named project or the raw id. I added two forgeries to the role choices. That gives 5 × 2 × 3 × 2 = 60 combinations. I generated a name() callback for each one, used src/index.ts verbatim from commit ebd0af9, re-declared the proofs and the sensitive function the way examples/basic does, and ran tsc 5.9.3 under the benchmark's compiler options.

role check
plan (entitlement) check
project argument
failed checks
name(viewer.id, a, b, async (user, project, other) => {
  const manage = await canViewProtection(user, project);
  if (!manage) throw new HttpError(403, "…");
  const plan = await planIncludesPasswordProtection(project);
  if (!plan) throw new HttpError(403, "…");
  await setPasswordProtection(project, user, pw, { manage, plan });
});
tsc: 1 error
error TS2322: Type 'UserIsProjectAdmin<N, M> | UserHasProjectAccess<N, M>' is not assignable to type 'CanManageProtection<N, M>'.
Type 'UserHasProjectAccess<N, M>' is not assignable to type 'UserIsProjectAdmin<N, M>'.
Measured: every combination was type-checked with tsc 5.9.3 against gdp-ts at commit ebd0af9, and the messages are verbatim. 3 of 60 compile: the honest path and two forgeries. The lint lines are reasoned from the rule source, not from a lint run.

Three of the 60 compile (measured):

  1. canManageProtection on this project, null handled, the plan checked on this project, the named project passed. The honest path.
  2. The same, but with the manage proof forged as {} as CanManageProtection<…>.
  3. The same, but with null as any as the manage proof.

Every other combination fails with a specific message (measured, quoted from tsc):

One caveat about reading these. tsc reports the first argument of a call that fails, so a raw id hides the proof errors behind it until you fix the id. The repo's own snapshot (examples/basic/test/mistakes.snapshot.txt, on TypeScript 7.0) lists 12 such mistakes with the same error codes. Where the two differ, it is in which letter a type parameter prints as. The full matrix is below.

receiptscaptured 2026-10-06

Every combination of four choices a caller can make before setPasswordProtection(project, user, password, { manage, plan }), type-checked. 60 combinations; 3 compile: the honest one, and two forgeries (an 'as' assertion to the proof type, and 'null as any'). The forgeries are what the lint preset exists for.

role checknull resultplan checkproject argtscfirst error
no role checkhandledno plan checknamederrorTS2345: Argument of type '{}' is not assignable to parameter of type '{ manage: CanManageProtection<N, M>; plan: PlanIncludesPasswordProtection<M>; }'.
no role checkhandledno plan checkraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<unknown, ProjectId>'.
no role checkhandledplan checked on this projectnamederrorTS2345: Argument of type '{ plan: PlanIncludesPasswordProtection<M>; }' is not assignable to parameter of type '{ manage: CanManageProtection<N, M>; plan: PlanIncludesPasswordProtection<M>; }'.
no role checkhandledplan checked on this projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
no role checkhandledplan checked on the other projectnamederrorTS2322: Type 'PlanIncludesPasswordProtection<O>' is not assignable to type 'PlanIncludesPasswordProtection<M>'.
no role checkhandledplan checked on the other projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<O, ProjectId>'.
no role checknot handledno plan checknamederrorTS2345: Argument of type '{}' is not assignable to parameter of type '{ manage: CanManageProtection<N, M>; plan: PlanIncludesPasswordProtection<M>; }'.
no role checknot handledno plan checkraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<unknown, ProjectId>'.
no role checknot handledplan checked on this projectnamederrorTS2322: Type 'PlanIncludesPasswordProtection<M> | null' is not assignable to type 'PlanIncludesPasswordProtection<M>'.
no role checknot handledplan checked on this projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
no role checknot handledplan checked on the other projectnamederrorTS2322: Type 'PlanIncludesPasswordProtection<O> | null' is not assignable to type 'PlanIncludesPasswordProtection<M>'.
no role checknot handledplan checked on the other projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<O, ProjectId>'.
canViewProtection (view)handledno plan checknamederrorTS2322: Type 'UserIsProjectAdmin<N, M> | UserHasProjectAccess<N, M>' is not assignable to type 'CanManageProtection<N, M>'.
canViewProtection (view)handledno plan checkraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
canViewProtection (view)handledplan checked on this projectnamederrorTS2322: Type 'UserIsProjectAdmin<N, M> | UserHasProjectAccess<N, M>' is not assignable to type 'CanManageProtection<N, M>'.
canViewProtection (view)handledplan checked on this projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
canViewProtection (view)handledplan checked on the other projectnamederrorTS2322: Type 'UserIsProjectAdmin<N, M> | UserHasProjectAccess<N, M>' is not assignable to type 'CanManageProtection<N, M>'.
canViewProtection (view)handledplan checked on the other projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
canViewProtection (view)not handledno plan checknamederrorTS2322: Type 'UserIsProjectAdmin<N, M> | UserHasProjectAccess<N, M> | null' is not assignable to type 'CanManageProtection<N, M>'.
canViewProtection (view)not handledno plan checkraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
canViewProtection (view)not handledplan checked on this projectnamederrorTS2322: Type 'UserIsProjectAdmin<N, M> | UserHasProjectAccess<N, M> | null' is not assignable to type 'CanManageProtection<N, M>'.
canViewProtection (view)not handledplan checked on this projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
canViewProtection (view)not handledplan checked on the other projectnamederrorTS2322: Type 'UserIsProjectAdmin<N, M> | UserHasProjectAccess<N, M> | null' is not assignable to type 'CanManageProtection<N, M>'.
canViewProtection (view)not handledplan checked on the other projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
canManageProtection (admin)handledno plan checknamederrorTS2345: Argument of type '{ manage: UserIsProjectAdmin<N, M>; }' is not assignable to parameter of type '{ manage: CanManageProtection<N, M>; plan: PlanIncludesPasswordProtection<M>; }'.
canManageProtection (admin)handledno plan checkraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
canManageProtection (admin)handledplan checked on this projectnamedcompiles—
canManageProtection (admin)handledplan checked on this projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
canManageProtection (admin)handledplan checked on the other projectnamederrorTS2322: Type 'PlanIncludesPasswordProtection<O>' is not assignable to type 'PlanIncludesPasswordProtection<M>'.
canManageProtection (admin)handledplan checked on the other projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
canManageProtection (admin)not handledno plan checknamederrorTS2322: Type 'UserIsProjectAdmin<N, M> | null' is not assignable to type 'CanManageProtection<N, M>'.
canManageProtection (admin)not handledno plan checkraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
canManageProtection (admin)not handledplan checked on this projectnamederrorTS2322: Type 'UserIsProjectAdmin<N, M> | null' is not assignable to type 'CanManageProtection<N, M>'.
canManageProtection (admin)not handledplan checked on this projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
canManageProtection (admin)not handledplan checked on the other projectnamederrorTS2322: Type 'UserIsProjectAdmin<N, M> | null' is not assignable to type 'CanManageProtection<N, M>'.
canManageProtection (admin)not handledplan checked on the other projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
forged: {} as CanManageProtectionhandledno plan checknamederrorTS2345: Argument of type '{ manage: CanManageProtection<N, M>; }' is not assignable to parameter of type '{ manage: CanManageProtection<N, M>; plan: PlanIncludesPasswordProtection<M>; }'.
forged: {} as CanManageProtectionhandledno plan checkraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
forged: {} as CanManageProtectionhandledplan checked on this projectnamedcompiles—
forged: {} as CanManageProtectionhandledplan checked on this projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
forged: {} as CanManageProtectionhandledplan checked on the other projectnamederrorTS2322: Type 'PlanIncludesPasswordProtection<O>' is not assignable to type 'PlanIncludesPasswordProtection<M>'.
forged: {} as CanManageProtectionhandledplan checked on the other projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
forged: {} as CanManageProtectionnot handledno plan checknamederrorTS2345: Argument of type '{ manage: CanManageProtection<N, M>; }' is not assignable to parameter of type '{ manage: CanManageProtection<N, M>; plan: PlanIncludesPasswordProtection<M>; }'.
forged: {} as CanManageProtectionnot handledno plan checkraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
forged: {} as CanManageProtectionnot handledplan checked on this projectnamederrorTS2322: Type 'PlanIncludesPasswordProtection<M> | null' is not assignable to type 'PlanIncludesPasswordProtection<M>'.
forged: {} as CanManageProtectionnot handledplan checked on this projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
forged: {} as CanManageProtectionnot handledplan checked on the other projectnamederrorTS2322: Type 'PlanIncludesPasswordProtection<O> | null' is not assignable to type 'PlanIncludesPasswordProtection<M>'.
forged: {} as CanManageProtectionnot handledplan checked on the other projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
forged: null as anyhandledno plan checknamederrorTS2345: Argument of type '{ manage: any; }' is not assignable to parameter of type '{ manage: CanManageProtection<N, M>; plan: PlanIncludesPasswordProtection<M>; }'.
forged: null as anyhandledno plan checkraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<unknown, ProjectId>'.
forged: null as anyhandledplan checked on this projectnamedcompiles—
forged: null as anyhandledplan checked on this projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
forged: null as anyhandledplan checked on the other projectnamederrorTS2322: Type 'PlanIncludesPasswordProtection<O>' is not assignable to type 'PlanIncludesPasswordProtection<M>'.
forged: null as anyhandledplan checked on the other projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<O, ProjectId>'.
forged: null as anynot handledno plan checknamederrorTS2345: Argument of type '{ manage: any; }' is not assignable to parameter of type '{ manage: CanManageProtection<N, M>; plan: PlanIncludesPasswordProtection<M>; }'.
forged: null as anynot handledno plan checkraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<unknown, ProjectId>'.
forged: null as anynot handledplan checked on this projectnamederrorTS2322: Type 'PlanIncludesPasswordProtection<M> | null' is not assignable to type 'PlanIncludesPasswordProtection<M>'.
forged: null as anynot handledplan checked on this projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<M, ProjectId>'.
forged: null as anynot handledplan checked on the other projectnamederrorTS2322: Type 'PlanIncludesPasswordProtection<O> | null' is not assignable to type 'PlanIncludesPasswordProtection<M>'.
forged: null as anynot handledplan checked on the other projectraw iderrorTS2345: Argument of type 'ProjectId' is not assignable to parameter of type 'Named<O, ProjectId>'.

TypeScript 5.9.3, not the 7.0 the repo's own snapshots use; the error codes match the repo's snapshot for the same mistakes, the type-parameter letters can differ.

method Generated one name() callback per combination from the gdp-ts README/examples pattern (src/index.ts copied verbatim from rauchg/gdp-ts at ebd0af9; proofs, policies and the sensitive function re-declared in one file following examples/basic). Ran tsc 5.9.3 with the repo bench's compiler options (strict, es2022, nodenext). Errors were attributed to a combination by line range; tsc reports the first failing argument of a call, so a raw id masks the proof errors behind it.
data /articles/gdp-ts/data/typecheck-matrix.json (60 rows, 17.9 KB)

What the linter adds

The type checker cannot see the difference between a proof that came out of proofs/ and one written by hand with as. The linter does that job. All five rules are syntactic, so they need no type information and run under ESLint, and under Oxlint on TypeScript 7:

RuleWhereCatches
no-define-proofoutside proofs/importing or calling defineProof, minting a new kind of proof anywhere but a trusted module
no-exported-proverinside proofs/exporting the prover directly, through export { X }, or as a default export
no-proof-assertionoutside proofs/as or <T>x to Named, Proof, or any type imported from a proofs path
no-type-assertionstrict modeevery other assertion except as const
no-anystrict modeevery any, which "silently satisfies any proof parameter"

Map that onto the two forgeries that compiled. {} as CanManageProtection<…> is an assertion to a type imported from proofs/, so the default preset reports it with Do not assert a proof or Named type. null as any is the gap. The default preset only watches assertions to proof types, and the README says it "cannot see null as any handed straight to a proof parameter". Strict mode bans every as and any outside proofs/ and lib/ids.ts, the branded-id constructors. The repo's shared lint test cases expect null as any to produce nothing in default mode and both strict rules in strict mode. I am reading that from the test file and the rule source. I did not run the linter (reasoned).

So new code wants strict mode; the default mode is for switching on in an existing codebase. Rauch said in the thread that this is why the linter exists at all: he "had to ship a linter to avoid would-be agents cheating the proof system with 𝚊𝚜 𝚊𝚗𝚢 shenanigans". An agent told to make the build green reaches for as any, and the type system accepts it.

cuda-oxide in CUDA Rust asks the same question: its ThreadIndex can only be minted from hardware registers. Rust enforces that private constructor; TypeScript needs a lint rule for it.

The skill

The third piece is skills/gdp-ts/SKILL.md, installed with npx skills add rauchg/gdp-ts. It is plain Markdown and doubles as the manual. Its workflow is ten steps for adding or changing authorization. A few of them carry most of the weight for an agent:

A page called Where to stop reins it in: name values that cross a trust boundary, not page sizes, and "if a signature starts needing four type parameters, you have gone too far." The authors expect the pattern to be over-applied, and agents follow a pattern harder than people do.

This is a harness decision as much as a library decision. Lilian Weng's point that guardrails live outside the loop applies here: the guarantee does not depend on the agent remembering the rule, because the compiler and the linter run whether it remembers or not.

What it costs to type-check

The repository includes a benchmark that generates two synthetic codebases with the same files and module graph, one with boolean checks and one with gdp-ts, at 1,000 to 20,000 files (all figures reported, Apple M5, medians of 5 runs):

TypeScript 7.0.2, 20,000 filesboolean checksgdp-ts
Check time1.17 s6.78 s
Total time4.04 s8.20 s
Checker memory1,207 MB4,743 MB

The per-call-site cost is what transfers. On TypeScript 7 it is about 280 µs of check time and 181 KB of memory per name() site, flat from 1,000 to 20,000 files. On 5.9 and 6.0 it is about 650 µs and 240 KB. TypeScript 5.9.3 and 6.0.3 ran out of memory on the 20,000-file gdp variant at Node's default 4,288 MB heap. The generated gdp code is about 35% more lines.

The benchmark says itself that nearly every generated line is authorization code, so the 5-6x ratio is an upper bound. For a service with 2,000 authorized call sites on TypeScript 7, that is 2,000 × 280 µs, about 0.56 s of extra check time and around 360 MB (reasoned). On 5.x in a large monorepo, the memory is what I would test first.

What it does not guarantee

The skill's limits page does not oversell. In my words:

The take

The mechanism is not new, and the repository says so on every page. What is new is the claim about who pays for it. GDP stayed niche in Haskell because humans had to write the type parameters, read the errors and review the result. Rauch's bet is that an agent will happily do the first two in a tight compile loop and that the compiler reduces the third. My matrix supports the narrow version of that bet. Every honest mistake I could generate in front of one sensitive call was a compile error with a message that names the wrong value, and the two things that slipped past the compiler are syntactic patterns a linter catches in strict mode.

The broader claim, that strict type systems matter more as agents write more of the code, is the same argument as Leanstral, where a model can do proof search because Lean will not accept a fake proof. gdp-ts is a much weaker checker than Lean, and says so. It covers one class of bug and ships the lint rule for the way around it. I would adopt it in strict mode, put the demand in the data layer, and spend the saved review time on proofs/.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "gdp-ts: making the authorization check a value the compiler can see", ai.thesatyajit.com, October 2026.

bibtex
@misc{ghana2026gdpts,
  author = {Satyajit Ghana},
  title  = {gdp-ts: making the authorization check a value the compiler can see},
  url    = {https://ai.thesatyajit.com/articles/gdp-ts},
  year   = {2026}
}
share