[{"data":1,"prerenderedAt":1630},["ShallowReactive",2],{"docs-sections":3,"docs-page-\u002Fdocs\u002Faccess":123},[4,9,13,17,22,26,30,34,38,42,46,50,55,59,63,67,72,76,80,85,89,93,97,102,106,110,114,119],{"path":5,"title":6,"description":7,"group":8},"\u002Fdocs","Introduction","What tunables is, the problem it solves, and the principles it holds to.","Get started",{"path":10,"title":11,"description":12,"group":8},"\u002Fdocs\u002Fquickstart","Quickstart","Take one group of settings from constants to a screen with a history, in a Nuxt app on Postgres.",{"path":14,"title":15,"description":16,"group":8},"\u002Fdocs\u002Fpatterns","What it is for","The kinds of value tunables keeps — live limits, plans, per-workspace and per-person values, model choices, values that bound each other, costly switches — and the feature that does each.",{"path":18,"title":19,"description":20,"group":21},"\u002Fdocs\u002Fsettings-and-groups","Settings and groups","Declare each setting once, in a group edited beside what it affects, and read it with types.","Concepts",{"path":23,"title":24,"description":25,"group":21},"\u002Fdocs\u002Fkinds-and-units","Kinds and units","The eight kinds of setting, the built-in units, your own units, and how values are checked and read.",{"path":27,"title":28,"description":29,"group":21},"\u002Fdocs\u002Fchecks-and-guards","Checks and guards","Rules across settings, guards for what your app keeps, within for presets, confirmations for costly changes, and notes.",{"path":31,"title":32,"description":33,"group":21},"\u002Fdocs\u002Fhistory-and-restore","History and restore","Every change is a revision with who, when, why and each value before and after; restoring one runs the same checks as any save.",{"path":35,"title":36,"description":37,"group":21},"\u002Fdocs\u002Fpresets","Presets","Named, reusable configurations with one default per slot, assigned by reference and bounded by the settings.",{"path":39,"title":40,"description":41,"group":21},"\u002Fdocs\u002Fscopes","Scopes","Values per workspace and per person, through layers — the system, a plan, the workspace, the person — each bounded by what it inherits.",{"path":43,"title":44,"description":45,"group":21},"\u002Fdocs\u002Fcatalogs","Catalogs","Lists of allowed choices, such as permitted models, whose entries are retired rather than deleted and cannot be retired while in use.",{"path":47,"title":48,"description":49,"group":21},"\u002Fdocs\u002Fdeployment-values","Deployment values","Values a deployment sets in its environment and nobody tunes while the app runs, declared once and listed in .env.example.",{"path":51,"title":52,"description":53,"group":54},"\u002Fdocs\u002Fstorage","Storage","Postgres through your own client, the four tables and how to create them, joining your transactions, and the storage contract behind it.","Integrate",{"path":56,"title":57,"description":58,"group":54},"\u002Fdocs\u002Fserver","Server and access","Mount tunables' API as a fetch handler in Nitro, Hono, Bun or Deno, as Express middleware or as an oRPC router, with access decided by your app.",{"path":60,"title":61,"description":62,"group":54},"\u002Fdocs\u002Faccess","Who can do what","End users bound by values they never see, staff who tune groups, owners and members who change only their own scope, and the terminal.",{"path":64,"title":65,"description":66,"group":54},"\u002Fdocs\u002Ftesting","Testing","Your app's tunables in memory for its own tests, with values set where a test needs them, and how to test rules, guards and confirmations.",{"path":68,"title":69,"description":70,"group":71},"\u002Fdocs\u002Fnuxt-layer","The Nuxt layer","Ready screens for a Nuxt UI app — a group's form, All settings, history, presets, assignments and catalogs — placed beside what they affect.","Screens",{"path":73,"title":74,"description":75,"group":71},"\u002Fdocs\u002Fvue-composables","Vue composables","Headless composables for building tunables' screens in any Vue app, on any design system.",{"path":77,"title":78,"description":79,"group":71},"\u002Fdocs\u002Ftheming","Theming","The screens bring no colors, fonts or CSS of their own; they take your Nuxt UI theme.",{"path":81,"title":82,"description":83,"group":84},"\u002Fdocs\u002Fcli","The CLI","The tunables command, tunables.config.ts, and which commands need a database.","CLI",{"path":86,"title":87,"description":88,"group":84},"\u002Fdocs\u002Fcli-check","Check and generate","The commands that need no database — checking definitions, writing .env.example, and keeping explanations and defaults in step with a pdd-kit Product Model.",{"path":90,"title":91,"description":92,"group":84},"\u002Fdocs\u002Fcli-values","Read and change values","The commands that read and change stored values, through the same checks and history as the screens.",{"path":94,"title":95,"description":96,"group":84},"\u002Fdocs\u002Fcli-skills","Install skills","Install tunables' agent skills for Claude Code, Codex CLI, Cursor, Gemini CLI and GitHub Copilot, and keep them current.",{"path":98,"title":99,"description":100,"group":101},"\u002Fdocs\u002Fskills","Agent skills","Three skills that let a coding agent adopt tunables, define settings the right way and verify that every value has one home.","Skills",{"path":103,"title":104,"description":105,"group":101},"\u002Fdocs\u002Fskill-adopt","tunables-adopt","Bring tunables into an app that has none yet, from an inventory of every value to a wired, synced app with the old homes removed.",{"path":107,"title":108,"description":109,"group":101},"\u002Fdocs\u002Fskill-define","tunables-define","Add, change, rename or remove one value in an app that uses tunables, with its home, its checks, its reads and what happens to stored values.",{"path":111,"title":112,"description":113,"group":101},"\u002Fdocs\u002Fskill-verify","tunables-verify","Audit an app's tunables against their rules, report every finding with where it is and the rule it breaks, and fix only the findings you choose.",{"path":115,"title":116,"description":117,"group":118},"\u002Fdocs\u002Fapi","API reference","Every export of the tunables package, by the subpath it is imported from.","Reference",{"path":120,"title":121,"description":122,"group":118},"\u002Fdocs\u002Ftasklane-example","Tasklane, the example app","A todo app with workspaces and people as tunables — two groups, plans as a layer, a catalog of models, scopes, a rule, a confirmation, and the playground that runs it.",{"id":124,"title":61,"body":125,"description":62,"extension":1625,"group":54,"meta":1626,"navigation":813,"order":1049,"path":60,"seo":1627,"stem":1628,"__hash__":1629},"docs\u002Fdocs\u002Faccess.md",{"type":126,"value":127,"toc":1615},"minimark",[128,132,154,159,162,514,528,532,565,737,746,750,767,778,1134,1153,1175,1180,1194,1197,1201,1211,1215,1230,1246,1268,1423,1485,1504,1508,1511,1540,1557,1603,1611],[129,130,61],"h1",{"id":131},"who-can-do-what",[133,134,135,136,148,149,153],"p",{},"tunables has no users, roles or sessions. Your app says who is asking and\nwhether they may, through the ",[137,138,140,144,145],"a",{"href":139},".\u002Fserver#access",[141,142,143],"code",{},"actor"," and ",[141,146,147],{},"authorize","\noptions of whichever adapter serves the API. This page walks through the three\ncases most apps have, with ",[137,150,152],{"href":151},".\u002Ftasklane-example","Tasklane","'s policy, and then\nthe terminal.",[155,156,158],"h2",{"id":157},"people-bound-by-values-they-never-change","People bound by values they never change",[133,160,161],{},"This is most of what tunables does. Your server reads a setting where it\ndecides something, and applies it to whoever made the request. That person\nnever calls tunables' API and needs no access to it:",[163,164,169],"pre",{"className":165,"code":166,"language":167,"meta":168,"style":168},"language-ts shiki shiki-themes tunables-panel-light tunables-panel-light tunables-panel-dark","\u002F\u002F Tasklane's own route, in examples\u002Fexpress\u002Fapp.ts\napp.post('\u002Fprojects', async (req, res) => {\n  const person = personOf(req)\n  if (!person) return void res.status(401).end()\n  const limits = await tunables.get('limits', { scope: { workspace: person.workspace, user: person.id } })\n  if (req.body.open >= limits.maxProjects) {\n    return void res.status(409).json({ error: `Your workspace may have ${limits.maxProjects} open projects.` })\n  }\n  res.status(201).json({ created: true })\n})\n","ts","",[141,170,171,180,236,258,300,376,410,469,475,508],{"__ignoreMap":168},[172,173,176],"span",{"class":174,"line":175},"line",1,[172,177,179],{"class":178},"sKllz","\u002F\u002F Tasklane's own route, in examples\u002Fexpress\u002Fapp.ts\n",[172,181,183,187,191,195,199,202,206,208,211,215,218,222,224,227,230,233],{"class":174,"line":182},2,[172,184,186],{"class":185},"sq0K3","app",[172,188,190],{"class":189},"sBd0f",".",[172,192,194],{"class":193},"sOThN","post",[172,196,198],{"class":197},"sp9ci","(",[172,200,201],{"class":197},"'",[172,203,205],{"class":204},"sjCzm","\u002Fprojects",[172,207,201],{"class":197},[172,209,210],{"class":197},",",[172,212,214],{"class":213},"sZ4mS"," async",[172,216,217],{"class":197}," (",[172,219,221],{"class":220},"sVz71","req",[172,223,210],{"class":197},[172,225,226],{"class":220}," res",[172,228,229],{"class":197},")",[172,231,232],{"class":213}," =>",[172,234,235],{"class":197}," {\n",[172,237,239,242,245,248,251,253,255],{"class":174,"line":238},3,[172,240,241],{"class":213},"  const",[172,243,244],{"class":220}," person ",[172,246,247],{"class":197},"=",[172,249,250],{"class":193}," personOf",[172,252,198],{"class":197},[172,254,221],{"class":220},[172,256,257],{"class":197},")\n",[172,259,261,264,267,270,272,275,278,280,282,285,287,290,292,294,297],{"class":174,"line":260},4,[172,262,263],{"class":213},"  if",[172,265,266],{"class":197}," (!",[172,268,269],{"class":220},"person",[172,271,229],{"class":197},[172,273,274],{"class":213}," return",[172,276,277],{"class":197}," void",[172,279,226],{"class":185},[172,281,190],{"class":189},[172,283,284],{"class":193},"status",[172,286,198],{"class":197},[172,288,289],{"class":204},"401",[172,291,229],{"class":197},[172,293,190],{"class":189},[172,295,296],{"class":193},"end",[172,298,299],{"class":197},"()\n",[172,301,303,305,308,310,313,316,318,321,323,325,328,330,332,335,338,341,343,346,348,351,353,356,358,361,363,365,367,370,373],{"class":174,"line":302},5,[172,304,241],{"class":213},[172,306,307],{"class":220}," limits ",[172,309,247],{"class":197},[172,311,312],{"class":213}," await",[172,314,315],{"class":185}," tunables",[172,317,190],{"class":189},[172,319,320],{"class":193},"get",[172,322,198],{"class":197},[172,324,201],{"class":197},[172,326,327],{"class":204},"limits",[172,329,201],{"class":197},[172,331,210],{"class":197},[172,333,334],{"class":197}," {",[172,336,337],{"class":220}," scope",[172,339,340],{"class":197},":",[172,342,334],{"class":197},[172,344,345],{"class":220}," workspace",[172,347,340],{"class":197},[172,349,350],{"class":220}," person",[172,352,190],{"class":197},[172,354,355],{"class":220},"workspace",[172,357,210],{"class":197},[172,359,360],{"class":220}," user",[172,362,340],{"class":197},[172,364,350],{"class":220},[172,366,190],{"class":197},[172,368,369],{"class":220},"id ",[172,371,372],{"class":197},"}",[172,374,375],{"class":197}," })\n",[172,377,379,381,383,385,387,390,392,395,398,401,403,406,408],{"class":174,"line":378},6,[172,380,263],{"class":213},[172,382,217],{"class":197},[172,384,221],{"class":220},[172,386,190],{"class":197},[172,388,389],{"class":220},"body",[172,391,190],{"class":197},[172,393,394],{"class":220},"open ",[172,396,397],{"class":197},">=",[172,399,400],{"class":220}," limits",[172,402,190],{"class":197},[172,404,405],{"class":220},"maxProjects",[172,407,229],{"class":197},[172,409,235],{"class":197},[172,411,413,416,418,420,422,424,426,429,431,433,436,439,442,444,447,450,453,455,457,459,461,464,467],{"class":174,"line":412},7,[172,414,415],{"class":213},"    return",[172,417,277],{"class":197},[172,419,226],{"class":185},[172,421,190],{"class":189},[172,423,284],{"class":193},[172,425,198],{"class":197},[172,427,428],{"class":204},"409",[172,430,229],{"class":197},[172,432,190],{"class":189},[172,434,435],{"class":193},"json",[172,437,438],{"class":197},"({",[172,440,441],{"class":220}," error",[172,443,340],{"class":197},[172,445,446],{"class":197}," `",[172,448,449],{"class":204},"Your workspace may have ",[172,451,452],{"class":197},"${",[172,454,327],{"class":220},[172,456,190],{"class":197},[172,458,405],{"class":220},[172,460,372],{"class":197},[172,462,463],{"class":204}," open projects.",[172,465,466],{"class":197},"`",[172,468,375],{"class":197},[172,470,472],{"class":174,"line":471},8,[172,473,474],{"class":197},"  }\n",[172,476,478,481,483,485,487,490,492,494,496,498,501,503,506],{"class":174,"line":477},9,[172,479,480],{"class":185},"  res",[172,482,190],{"class":189},[172,484,284],{"class":193},[172,486,198],{"class":197},[172,488,489],{"class":204},"201",[172,491,229],{"class":197},[172,493,190],{"class":189},[172,495,435],{"class":193},[172,497,438],{"class":197},[172,499,500],{"class":220}," created",[172,502,340],{"class":197},[172,504,505],{"class":213}," true",[172,507,375],{"class":197},[172,509,511],{"class":174,"line":510},10,[172,512,513],{"class":197},"})\n",[133,515,516,519,520,522,523,527],{},[141,517,518],{},"tunables.get"," is not behind ",[141,521,147],{},": it is your code, running on your\nserver. Read at the ",[137,524,526],{"href":525},".\u002Fscopes","scope"," of the person asking, and they get\nthe value meant for them, from their plan, their workspace and their own\nsettings.",[155,529,531],{"id":530},"staff-who-tune-groups","Staff who tune groups",[133,533,534,535,537,538,541,542,545,546,549,550,553,554,541,557,560,561,564],{},"The people who tune the product are allowed by group. ",[141,536,147],{}," receives\nthe action (",[141,539,540],{},"read"," or ",[141,543,544],{},"change",") and the target (",[141,547,548],{},"description",", ",[141,551,552],{},"group",",\n",[141,555,556],{},"presets",[141,558,559],{},"catalog",", with its ",[141,562,563],{},"id","):",[163,566,568],{"className":165,"code":567,"language":167,"meta":168,"style":168},"authorize: (context, { action, target }) => {\n  const user = context.user\n  if (!user?.staff) return false\n  if (action === 'read') return true\n  \u002F\u002F Support may retire a model; only engineers change limits and plans.\n  return user.role === 'engineer' || (target.kind === 'catalog' && user.role === 'support')\n}\n",[141,569,570,600,617,639,665,670,732],{"__ignoreMap":168},[172,571,572,574,576,578,581,583,585,588,590,593,596,598],{"class":174,"line":175},[172,573,147],{"class":220},[172,575,340],{"class":197},[172,577,217],{"class":197},[172,579,580],{"class":220},"context",[172,582,210],{"class":197},[172,584,334],{"class":197},[172,586,587],{"class":220}," action",[172,589,210],{"class":197},[172,591,592],{"class":220}," target ",[172,594,595],{"class":197},"})",[172,597,232],{"class":213},[172,599,235],{"class":197},[172,601,602,604,607,609,612,614],{"class":174,"line":182},[172,603,241],{"class":213},[172,605,606],{"class":220}," user ",[172,608,247],{"class":197},[172,610,611],{"class":220}," context",[172,613,190],{"class":197},[172,615,616],{"class":220},"user\n",[172,618,619,621,623,626,629,632,634,636],{"class":174,"line":238},[172,620,263],{"class":213},[172,622,266],{"class":197},[172,624,625],{"class":220},"user",[172,627,628],{"class":197},"?.",[172,630,631],{"class":220},"staff",[172,633,229],{"class":197},[172,635,274],{"class":213},[172,637,638],{"class":213}," false\n",[172,640,641,643,645,648,651,654,656,658,660,662],{"class":174,"line":260},[172,642,263],{"class":213},[172,644,217],{"class":197},[172,646,647],{"class":220},"action ",[172,649,650],{"class":197},"===",[172,652,653],{"class":197}," '",[172,655,540],{"class":204},[172,657,201],{"class":197},[172,659,229],{"class":197},[172,661,274],{"class":213},[172,663,664],{"class":213}," true\n",[172,666,667],{"class":174,"line":302},[172,668,669],{"class":178},"  \u002F\u002F Support may retire a model; only engineers change limits and plans.\n",[172,671,672,675,677,679,682,684,686,689,691,694,696,699,701,704,706,708,710,712,715,717,719,721,723,725,728,730],{"class":174,"line":378},[172,673,674],{"class":213},"  return",[172,676,360],{"class":220},[172,678,190],{"class":197},[172,680,681],{"class":220},"role ",[172,683,650],{"class":197},[172,685,653],{"class":197},[172,687,688],{"class":204},"engineer",[172,690,201],{"class":197},[172,692,693],{"class":197}," ||",[172,695,217],{"class":197},[172,697,698],{"class":220},"target",[172,700,190],{"class":197},[172,702,703],{"class":220},"kind ",[172,705,650],{"class":197},[172,707,653],{"class":197},[172,709,559],{"class":204},[172,711,201],{"class":197},[172,713,714],{"class":197}," &&",[172,716,360],{"class":220},[172,718,190],{"class":197},[172,720,681],{"class":220},[172,722,650],{"class":197},[172,724,653],{"class":197},[172,726,727],{"class":204},"support",[172,729,201],{"class":197},[172,731,257],{"class":197},[172,733,734],{"class":174,"line":412},[172,735,736],{"class":197},"}\n",[133,738,739,740,742,743,745],{},"Reading history is a ",[141,741,540],{}," of what it is the history of; restoring is a\n",[141,744,544],{}," of the group. Guard the admin pages with your own route middleware\ntoo; the screens never decide who sees them.",[155,747,749],{"id":748},"self-service-per-scope","Self-service per scope",[133,751,752,753,755,756,758,759,762,763,766],{},"Owners and members can change only their own scope. For a group, ",[141,754,698],{},"\ncarries the scope path in ",[141,757,526],{},": ",[141,760,761],{},"{ workspace: 'acme' }"," for Acme's own\nvalues, ",[141,764,765],{},"{ workspace: 'acme', user: 'dana' }"," for Dana's. It is there for\nreads, saves, resets, history and restore. A request without a scope is about\nthe whole system.",[133,768,769,770,340],{},"Tasklane's policy, from\n",[137,771,775],{"href":772,"rel":773},"https:\u002F\u002Fgithub.com\u002Fitai-gendler\u002Ftunables\u002Fblob\u002Fmain\u002Fexamples\u002Ftasklane\u002Faccess.ts",[774],"nofollow",[141,776,777],{},"examples\u002Ftasklane\u002Faccess.ts",[163,779,781],{"className":165,"code":780,"language":167,"meta":168,"style":168},"import type { AccessTarget } from 'tunables\u002Fserver'\n\nexport function mayUse(person: Person | undefined, target: AccessTarget): boolean {\n  if (!person) return false\n  if (person.staff) return true\n  \u002F\u002F Everyone signed in reads the description, so their own settings screen can show.\n  if (target.kind === 'description') return true\n  if (target.kind !== 'group' || target.scope?.workspace !== person.workspace) return false\n  return target.scope.user ? target.scope.user === person.id : Boolean(person.owner)\n}\n\ncreateHandler({\n  tunables,\n  actor: person => person && { id: person.id, name: person.name },\n  authorize: (person, { target }) => mayUse(person, target)\n})\n",[141,782,783,809,815,858,872,890,895,921,969,1021,1025,1030,1039,1047,1095,1129],{"__ignoreMap":168},[172,784,785,788,791,793,796,798,801,803,806],{"class":174,"line":175},[172,786,787],{"class":213},"import",[172,789,790],{"class":213}," type",[172,792,334],{"class":197},[172,794,795],{"class":220}," AccessTarget ",[172,797,372],{"class":197},[172,799,800],{"class":213}," from",[172,802,653],{"class":197},[172,804,805],{"class":204},"tunables\u002Fserver",[172,807,808],{"class":197},"'\n",[172,810,811],{"class":174,"line":182},[172,812,814],{"emptyLinePlaceholder":813},true,"\n",[172,816,817,820,823,826,828,830,832,835,838,841,843,846,848,851,853,856],{"class":174,"line":238},[172,818,819],{"class":213},"export",[172,821,822],{"class":213}," function",[172,824,825],{"class":193}," mayUse",[172,827,198],{"class":197},[172,829,269],{"class":220},[172,831,340],{"class":197},[172,833,834],{"class":193}," Person",[172,836,837],{"class":197}," |",[172,839,840],{"class":193}," undefined",[172,842,210],{"class":197},[172,844,845],{"class":220}," target",[172,847,340],{"class":197},[172,849,850],{"class":193}," AccessTarget",[172,852,564],{"class":197},[172,854,855],{"class":193}," boolean",[172,857,235],{"class":197},[172,859,860,862,864,866,868,870],{"class":174,"line":260},[172,861,263],{"class":213},[172,863,266],{"class":197},[172,865,269],{"class":220},[172,867,229],{"class":197},[172,869,274],{"class":213},[172,871,638],{"class":213},[172,873,874,876,878,880,882,884,886,888],{"class":174,"line":302},[172,875,263],{"class":213},[172,877,217],{"class":197},[172,879,269],{"class":220},[172,881,190],{"class":197},[172,883,631],{"class":220},[172,885,229],{"class":197},[172,887,274],{"class":213},[172,889,664],{"class":213},[172,891,892],{"class":174,"line":378},[172,893,894],{"class":178},"  \u002F\u002F Everyone signed in reads the description, so their own settings screen can show.\n",[172,896,897,899,901,903,905,907,909,911,913,915,917,919],{"class":174,"line":412},[172,898,263],{"class":213},[172,900,217],{"class":197},[172,902,698],{"class":220},[172,904,190],{"class":197},[172,906,703],{"class":220},[172,908,650],{"class":197},[172,910,653],{"class":197},[172,912,548],{"class":204},[172,914,201],{"class":197},[172,916,229],{"class":197},[172,918,274],{"class":213},[172,920,664],{"class":213},[172,922,923,925,927,929,931,933,936,938,940,942,944,946,948,950,952,955,957,959,961,963,965,967],{"class":174,"line":471},[172,924,263],{"class":213},[172,926,217],{"class":197},[172,928,698],{"class":220},[172,930,190],{"class":197},[172,932,703],{"class":220},[172,934,935],{"class":197},"!==",[172,937,653],{"class":197},[172,939,552],{"class":204},[172,941,201],{"class":197},[172,943,693],{"class":197},[172,945,845],{"class":220},[172,947,190],{"class":197},[172,949,526],{"class":220},[172,951,628],{"class":197},[172,953,954],{"class":220},"workspace ",[172,956,935],{"class":197},[172,958,350],{"class":220},[172,960,190],{"class":197},[172,962,355],{"class":220},[172,964,229],{"class":197},[172,966,274],{"class":213},[172,968,638],{"class":213},[172,970,971,973,975,977,979,981,984,987,989,991,993,995,997,999,1001,1003,1005,1007,1010,1012,1014,1016,1019],{"class":174,"line":477},[172,972,674],{"class":213},[172,974,845],{"class":220},[172,976,190],{"class":197},[172,978,526],{"class":220},[172,980,190],{"class":197},[172,982,983],{"class":220},"user ",[172,985,986],{"class":197},"?",[172,988,845],{"class":220},[172,990,190],{"class":197},[172,992,526],{"class":220},[172,994,190],{"class":197},[172,996,983],{"class":220},[172,998,650],{"class":197},[172,1000,350],{"class":220},[172,1002,190],{"class":197},[172,1004,369],{"class":220},[172,1006,340],{"class":197},[172,1008,1009],{"class":193}," Boolean",[172,1011,198],{"class":197},[172,1013,269],{"class":220},[172,1015,190],{"class":197},[172,1017,1018],{"class":220},"owner",[172,1020,257],{"class":197},[172,1022,1023],{"class":174,"line":510},[172,1024,736],{"class":197},[172,1026,1028],{"class":174,"line":1027},11,[172,1029,814],{"emptyLinePlaceholder":813},[172,1031,1033,1036],{"class":174,"line":1032},12,[172,1034,1035],{"class":193},"createHandler",[172,1037,1038],{"class":197},"({\n",[172,1040,1042,1045],{"class":174,"line":1041},13,[172,1043,1044],{"class":220},"  tunables",[172,1046,553],{"class":197},[172,1048,1050,1053,1055,1057,1060,1062,1065,1067,1070,1072,1074,1076,1078,1080,1083,1085,1087,1089,1092],{"class":174,"line":1049},14,[172,1051,1052],{"class":193},"  actor",[172,1054,340],{"class":197},[172,1056,244],{"class":220},[172,1058,1059],{"class":213},"=>",[172,1061,244],{"class":220},[172,1063,1064],{"class":197},"&&",[172,1066,334],{"class":197},[172,1068,1069],{"class":220}," id",[172,1071,340],{"class":197},[172,1073,350],{"class":220},[172,1075,190],{"class":197},[172,1077,563],{"class":220},[172,1079,210],{"class":197},[172,1081,1082],{"class":220}," name",[172,1084,340],{"class":197},[172,1086,350],{"class":220},[172,1088,190],{"class":197},[172,1090,1091],{"class":220},"name ",[172,1093,1094],{"class":197},"},\n",[172,1096,1098,1101,1103,1105,1107,1109,1111,1113,1115,1117,1119,1121,1123,1125,1127],{"class":174,"line":1097},15,[172,1099,1100],{"class":193},"  authorize",[172,1102,340],{"class":197},[172,1104,217],{"class":197},[172,1106,269],{"class":220},[172,1108,210],{"class":197},[172,1110,334],{"class":197},[172,1112,592],{"class":220},[172,1114,595],{"class":197},[172,1116,232],{"class":213},[172,1118,825],{"class":193},[172,1120,198],{"class":197},[172,1122,269],{"class":220},[172,1124,210],{"class":197},[172,1126,845],{"class":220},[172,1128,257],{"class":197},[172,1130,1132],{"class":174,"line":1131},16,[172,1133,513],{"class":197},[1135,1136,1137,1141,1147],"ul",{},[1138,1139,1140],"li",{},"Sam, on Tasklane's staff, may do anything.",[1138,1142,1143,1144,1146],{},"Olivia owns Acme. She may read and change ",[141,1145,761],{},", and\nnothing else: not the system values, not the plans, not Dana's own\nsettings.",[1138,1148,1149,1150,1152],{},"Dana is a member of Acme. She may read and change\n",[141,1151,765],{}," only.",[133,1154,1155,1156,1162,1163,1165,1166,1170,1171,1174],{},"Within their scope, ",[137,1157,1159],{"href":1158},".\u002Fscopes#declaring-what-may-be-overridden",[141,1160,1161],{},"override","\nstill decides which fields they may set and how far. ",[141,1164,147],{}," decides\n",[1167,1168,1169],"em",{},"whose"," values someone reaches; the definitions decide ",[1167,1172,1173],{},"which"," values those\nare.",[1176,1177,1179],"h3",{"id":1178},"the-description-must-be-readable","The description must be readable",[133,1181,1182,1183,1186,1187,1189,1190,1193],{},"A screen needs the definitions before it can draw a form, and it reads them\nfrom ",[141,1184,1185],{},"describe",". Allow ",[141,1188,540],{}," of ",[141,1191,1192],{},"{ kind: 'description' }"," to everyone who has\na self-service screen. The description holds labels, explanations, defaults\nand bounds, never stored values.",[133,1195,1196],{},"It matters for one more reason: tunables answers a malformed request, such as\na scope path the chain does not have, with a 400 only to someone allowed to\nread the description. To anyone else it is refused like any other request, so\na stranger learns nothing from probing.",[1176,1198,1200],{"id":1199},"refusal-is-404","Refusal is 404",[133,1202,1203,1204,1207,1208,1210],{},"A refused request answers 404 by default, the same as a path that does not\nexist, so someone outside the administration cannot tell it is there. Pass\n",[141,1205,1206],{},"refusal: 'forbidden'"," to answer 401 to nobody signed in and 403 to someone\nwho may not. A refused ",[1167,1209,544],{},", one that was allowed but failed a check, is\ndifferent: it is an answer with its problems, not an error.",[155,1212,1214],{"id":1213},"the-terminal","The terminal",[133,1216,1217,549,1220,549,1223,144,1226,1229],{},[141,1218,1219],{},"tunables get",[141,1221,1222],{},"set",[141,1224,1225],{},"history",[141,1227,1228],{},"restore"," work two ways.",[133,1231,1232,1239,1240,1242,1243,190],{},[1233,1234,1235,1236],"strong",{},"Through ",[141,1237,1238],{},"tunables.config.ts",", the default, they call your app's tunables\ndirectly, over your database. ",[141,1241,147],{}," is not asked: whoever runs them\nalready has the database credentials, which reach everything. Changes are\nrecorded as ",[141,1244,1245],{},"\u003Cusername> (terminal)",[133,1247,1248,1251,1252,541,1255,1258,1259,1262,1263,144,1265,1267],{},[1233,1249,1250],{},"Through your app's API",", with ",[141,1253,1254],{},"--url",[141,1256,1257],{},"TUNABLES_URL",", they call the same\nhandler the screens do and send ",[141,1260,1261],{},"TUNABLES_TOKEN"," as a bearer token. Your\n",[141,1264,143],{},[141,1266,147],{}," apply, and the revision records the actor your app\nnames for that token. No config file is needed. This is how to give a deploy\nbot or an operator access to some groups or some scopes without database\ncredentials:",[163,1269,1271],{"className":165,"code":1270,"language":167,"meta":168,"style":168},"actor: (context) => {\n  const token = context.headers.get('authorization')?.replace(\u002F^Bearer \u002F, '')\n  if (token && token === process.env.PLANS_BOT_TOKEN) return { id: 'plans-bot', name: 'Plans bot' }\n  return sessionUser(context)\n}\n",[141,1272,1273,1289,1345,1406,1419],{"__ignoreMap":168},[172,1274,1275,1277,1279,1281,1283,1285,1287],{"class":174,"line":175},[172,1276,143],{"class":220},[172,1278,340],{"class":197},[172,1280,217],{"class":197},[172,1282,580],{"class":220},[172,1284,229],{"class":197},[172,1286,232],{"class":213},[172,1288,235],{"class":197},[172,1290,1291,1293,1296,1298,1300,1302,1305,1307,1309,1311,1313,1316,1318,1320,1322,1325,1327,1330,1333,1336,1338,1340,1343],{"class":174,"line":182},[172,1292,241],{"class":213},[172,1294,1295],{"class":220}," token ",[172,1297,247],{"class":197},[172,1299,611],{"class":185},[172,1301,190],{"class":189},[172,1303,1304],{"class":185},"headers",[172,1306,190],{"class":189},[172,1308,320],{"class":193},[172,1310,198],{"class":197},[172,1312,201],{"class":197},[172,1314,1315],{"class":204},"authorization",[172,1317,201],{"class":197},[172,1319,229],{"class":197},[172,1321,628],{"class":189},[172,1323,1324],{"class":193},"replace",[172,1326,198],{"class":197},[172,1328,1329],{"class":197},"\u002F",[172,1331,1332],{"class":213},"^",[172,1334,1335],{"class":204},"Bearer ",[172,1337,1329],{"class":197},[172,1339,210],{"class":197},[172,1341,1342],{"class":197}," ''",[172,1344,257],{"class":197},[172,1346,1347,1349,1351,1354,1356,1358,1360,1363,1365,1368,1370,1373,1375,1377,1379,1381,1383,1385,1388,1390,1392,1394,1396,1398,1401,1403],{"class":174,"line":238},[172,1348,263],{"class":213},[172,1350,217],{"class":197},[172,1352,1353],{"class":220},"token ",[172,1355,1064],{"class":197},[172,1357,1295],{"class":220},[172,1359,650],{"class":197},[172,1361,1362],{"class":220}," process",[172,1364,190],{"class":197},[172,1366,1367],{"class":220},"env",[172,1369,190],{"class":197},[172,1371,1372],{"class":220},"PLANS_BOT_TOKEN",[172,1374,229],{"class":197},[172,1376,274],{"class":213},[172,1378,334],{"class":197},[172,1380,1069],{"class":220},[172,1382,340],{"class":197},[172,1384,653],{"class":197},[172,1386,1387],{"class":204},"plans-bot",[172,1389,201],{"class":197},[172,1391,210],{"class":197},[172,1393,1082],{"class":220},[172,1395,340],{"class":197},[172,1397,653],{"class":197},[172,1399,1400],{"class":204},"Plans bot",[172,1402,201],{"class":197},[172,1404,1405],{"class":197}," }\n",[172,1407,1408,1410,1413,1415,1417],{"class":174,"line":260},[172,1409,674],{"class":213},[172,1411,1412],{"class":193}," sessionUser",[172,1414,198],{"class":197},[172,1416,580],{"class":220},[172,1418,257],{"class":197},[172,1420,1421],{"class":174,"line":302},[172,1422,736],{"class":197},[163,1424,1428],{"className":1425,"code":1426,"language":1427,"meta":168,"style":168},"language-bash shiki shiki-themes tunables-panel-light tunables-panel-light tunables-panel-dark","TUNABLES_TOKEN=… npx tunables set limits maxProjects=20 \\\n  --url https:\u002F\u002Ftasklane.example.com\u002Fapi\u002Ftunables \\\n  --scope workspace=acme --reason \"Agreed with Acme's owner\"\n","bash",[141,1429,1430,1455,1465],{"__ignoreMap":168},[172,1431,1432,1434,1436,1439,1442,1444,1447,1449,1452],{"class":174,"line":175},[172,1433,1261],{"class":220},[172,1435,247],{"class":197},[172,1437,1438],{"class":204},"…",[172,1440,1441],{"class":193}," npx",[172,1443,315],{"class":204},[172,1445,1446],{"class":204}," set",[172,1448,400],{"class":204},[172,1450,1451],{"class":204}," maxProjects=20",[172,1453,1454],{"class":220}," \\\n",[172,1456,1457,1460,1463],{"class":174,"line":182},[172,1458,1459],{"class":204},"  --url",[172,1461,1462],{"class":204}," https:\u002F\u002Ftasklane.example.com\u002Fapi\u002Ftunables",[172,1464,1454],{"class":220},[172,1466,1467,1470,1473,1476,1479,1482],{"class":174,"line":238},[172,1468,1469],{"class":204},"  --scope",[172,1471,1472],{"class":204}," workspace=acme",[172,1474,1475],{"class":204}," --reason",[172,1477,1478],{"class":197}," \"",[172,1480,1481],{"class":204},"Agreed with Acme's owner",[172,1483,1484],{"class":197},"\"\n",[133,1486,1487,1488,1496,1497,1500,1501,190],{},"A refusal prints what the app answered, such as\n",[1167,1489,1490,1491,1495],{},"\"✗ ",[137,1492,1493],{"href":1493,"rel":1494},"https:\u002F\u002Ftasklane.example.com\u002Fapi\u002Ftunables",[774]," answered 404: Not found. Check\nthe URL, and that TUNABLES_TOKEN is one the app allows this.\""," ",[141,1498,1499],{},"sync"," always\nneeds the database. See ",[137,1502,91],{"href":1503},".\u002Fcli-values",[155,1505,1507],{"id":1506},"run-it","Run it",[133,1509,1510],{},"The repository's examples serve Tasklane with this policy:",[1135,1512,1513,1527],{},[1138,1514,1515,1522,1523,1526],{},[137,1516,1519],{"href":1517,"rel":1518},"https:\u002F\u002Fgithub.com\u002Fitai-gendler\u002Ftunables\u002Ftree\u002Fmain\u002Fexamples\u002Fexpress",[774],[141,1520,1521],{},"examples\u002Fexpress",":\n",[141,1524,1525],{},"npx jiti examples\u002Fexpress\u002Fserver.ts",", on port 3300;",[1138,1528,1529,1522,1536,1539],{},[137,1530,1533],{"href":1531,"rel":1532},"https:\u002F\u002Fgithub.com\u002Fitai-gendler\u002Ftunables\u002Ftree\u002Fmain\u002Fexamples\u002Fhono",[774],[141,1534,1535],{},"examples\u002Fhono",[141,1537,1538],{},"npx jiti examples\u002Fhono\u002Fserver.ts",", on port 3301.",[133,1541,1542,1543,1546,1547,549,1550,1553,1554,190],{},"Each takes an ",[141,1544,1545],{},"x-person"," header in place of a session: ",[141,1548,1549],{},"sam",[141,1551,1552],{},"olivia"," or\n",[141,1555,1556],{},"dana",[163,1558,1560],{"className":1425,"code":1559,"language":1427,"meta":168,"style":168},"curl -H 'x-person: olivia' 'http:\u002F\u002Flocalhost:3300\u002Fapi\u002Ftunables\u002Fgroups\u002Flimits?scope=workspace%3Dacme'\ncurl -H 'x-person: dana' 'http:\u002F\u002Flocalhost:3300\u002Fapi\u002Ftunables\u002Fgroups\u002Flimits?scope=workspace%3Dacme'\n",[141,1561,1562,1584],{"__ignoreMap":168},[172,1563,1564,1567,1570,1572,1575,1577,1579,1582],{"class":174,"line":175},[172,1565,1566],{"class":193},"curl",[172,1568,1569],{"class":204}," -H",[172,1571,653],{"class":197},[172,1573,1574],{"class":204},"x-person: olivia",[172,1576,201],{"class":197},[172,1578,653],{"class":197},[172,1580,1581],{"class":204},"http:\u002F\u002Flocalhost:3300\u002Fapi\u002Ftunables\u002Fgroups\u002Flimits?scope=workspace%3Dacme",[172,1583,808],{"class":197},[172,1585,1586,1588,1590,1592,1595,1597,1599,1601],{"class":174,"line":182},[172,1587,1566],{"class":193},[172,1589,1569],{"class":204},[172,1591,653],{"class":197},[172,1593,1594],{"class":204},"x-person: dana",[172,1596,201],{"class":197},[172,1598,653],{"class":197},[172,1600,1581],{"class":204},[172,1602,808],{"class":197},[133,1604,1605,1606,1610],{},"The first answers Acme's limits; the second answers 404. The\n",[137,1607,1609],{"href":1608},".\u002Ftasklane-example#the-playground","playground"," shows the same three people\non screens.",[1612,1613,1614],"style",{},"html pre.shiki code .sKllz, html code.shiki .sKllz{--shiki-light:#4F5D63;--shiki-light-font-style:italic;--shiki-default:#4F5D63;--shiki-default-font-style:italic;--shiki-dark:#A3AFB4;--shiki-dark-font-style:italic}html pre.shiki code .sq0K3, html code.shiki .sq0K3{--shiki-light:#182025;--shiki-light-font-weight:bold;--shiki-default:#182025;--shiki-default-font-weight:bold;--shiki-dark:#E6EBEC;--shiki-dark-font-weight:bold}html pre.shiki code .sBd0f, html code.shiki .sBd0f{--shiki-light:#4F5D63;--shiki-light-font-weight:bold;--shiki-default:#4F5D63;--shiki-default-font-weight:bold;--shiki-dark:#A3AFB4;--shiki-dark-font-weight:bold}html pre.shiki code .sOThN, html code.shiki .sOThN{--shiki-light:#2A3439;--shiki-light-font-weight:bold;--shiki-default:#2A3439;--shiki-default-font-weight:bold;--shiki-dark:#F6F8F8;--shiki-dark-font-weight:bold}html pre.shiki code .sp9ci, html code.shiki .sp9ci{--shiki-light:#4F5D63;--shiki-default:#4F5D63;--shiki-dark:#A3AFB4}html pre.shiki code .sjCzm, html code.shiki .sjCzm{--shiki-light:#92400E;--shiki-default:#92400E;--shiki-dark:#FCD34D}html pre.shiki code .sZ4mS, html code.shiki .sZ4mS{--shiki-light:#115E59;--shiki-default:#115E59;--shiki-dark:#5EEAD4}html pre.shiki code .sVz71, html code.shiki .sVz71{--shiki-light:#182025;--shiki-default:#182025;--shiki-dark:#E6EBEC}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":168,"searchDepth":182,"depth":182,"links":1616},[1617,1618,1619,1623,1624],{"id":157,"depth":182,"text":158},{"id":530,"depth":182,"text":531},{"id":748,"depth":182,"text":749,"children":1620},[1621,1622],{"id":1178,"depth":238,"text":1179},{"id":1199,"depth":238,"text":1200},{"id":1213,"depth":182,"text":1214},{"id":1506,"depth":182,"text":1507},"md",{},{"title":61,"description":62},"docs\u002Faccess","-0fzY6mZ9JI-Q4wvxMgxfosBU5b32YxOCe9OQVzc4ak",1792845117977]