main.mo
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Knowtation Attestation Canister — immutable append-only ledger for AIR attestation records. |
| 3 | * |
| 4 | * AIR Improvement E: blockchain-backed attestation on ICP. |
| 5 | * Records are write-once: no update, no delete. Once stored, permanent. |
| 6 | * |
| 7 | * Access control: |
| 8 | * storeAttestation → authorized callers only (gateway identity) |
| 9 | * setAuthorizedCallers → canister controllers only |
| 10 | * getAttestation → public query (anyone can verify) |
| 11 | * listAttestations → public query (transparency) |
| 12 | * getStats → public query (canister health) |
| 13 | * |
| 14 | * HTTP interface (via IC HTTP gateway): |
| 15 | * GET /health → {"ok":true} |
| 16 | * GET /attest/<id> → attestation record JSON or 404 |
| 17 | * GET /stats → { total, nextSeq } |
| 18 | */ |
| 19 | |
| 20 | import Array "mo:base/Array"; |
| 21 | import Blob "mo:base/Blob"; |
| 22 | import Buffer "mo:base/Buffer"; |
| 23 | import Char "mo:base/Char"; |
| 24 | import HashMap "mo:base/HashMap"; |
| 25 | import Int "mo:base/Int"; |
| 26 | import Iter "mo:base/Iter"; |
| 27 | import Nat "mo:base/Nat"; |
| 28 | import Nat32 "mo:base/Nat32"; |
| 29 | import Principal "mo:base/Principal"; |
| 30 | import Text "mo:base/Text"; |
| 31 | import Time "mo:base/Time"; |
| 32 | |
| 33 | persistent actor Attestation { |
| 34 | |
| 35 | // ------------------------------------------------------------------------- |
| 36 | // Types |
| 37 | // ------------------------------------------------------------------------- |
| 38 | |
| 39 | type AttestationRecord = { |
| 40 | id : Text; |
| 41 | action : Text; |
| 42 | path : Text; |
| 43 | timestamp : Text; |
| 44 | content_hash : Text; |
| 45 | sig : Text; |
| 46 | seq : Nat; |
| 47 | stored_at : Text; |
| 48 | }; |
| 49 | |
| 50 | type StoreInput = { |
| 51 | id : Text; |
| 52 | action : Text; |
| 53 | path : Text; |
| 54 | timestamp : Text; |
| 55 | content_hash : Text; |
| 56 | sig : Text; |
| 57 | }; |
| 58 | |
| 59 | type StoreResult = { |
| 60 | #ok : { seq : Nat }; |
| 61 | #err : Text; |
| 62 | }; |
| 63 | |
| 64 | type ListResult = { |
| 65 | records : [AttestationRecord]; |
| 66 | total : Nat; |
| 67 | }; |
| 68 | |
| 69 | type Header = (Text, Text); |
| 70 | type HttpRequest = { |
| 71 | method : Text; |
| 72 | url : Text; |
| 73 | headers : [Header]; |
| 74 | body : Blob; |
| 75 | }; |
| 76 | type HttpResponse = { |
| 77 | status_code : Nat16; |
| 78 | headers : [Header]; |
| 79 | body : Blob; |
| 80 | streaming_strategy : ?{ |
| 81 | #Callback : { |
| 82 | callback : shared query (StreamingCallbackToken) -> async StreamingCallbackResponse; |
| 83 | token : StreamingCallbackToken; |
| 84 | }; |
| 85 | }; |
| 86 | upgrade : ?Bool; |
| 87 | }; |
| 88 | type StreamingCallbackToken = { |
| 89 | key : Text; |
| 90 | content_encoding : Text; |
| 91 | index : Nat; |
| 92 | sha256 : ?Blob; |
| 93 | }; |
| 94 | type StreamingCallbackResponse = { |
| 95 | body : Blob; |
| 96 | token : ?StreamingCallbackToken; |
| 97 | }; |
| 98 | |
| 99 | // ------------------------------------------------------------------------- |
| 100 | // Stable state |
| 101 | // ------------------------------------------------------------------------- |
| 102 | |
| 103 | var entries : [(Text, AttestationRecord)] = []; |
| 104 | var nextSeq : Nat = 0; |
| 105 | var authorizedCallers : [Principal] = []; |
| 106 | |
| 107 | // ------------------------------------------------------------------------- |
| 108 | // Transient (rebuilt on upgrade) |
| 109 | // ------------------------------------------------------------------------- |
| 110 | |
| 111 | transient var byId = HashMap.HashMap<Text, AttestationRecord>(64, Text.equal, Text.hash); |
| 112 | transient var ordered = Buffer.Buffer<AttestationRecord>(64); |
| 113 | |
| 114 | func loadTransient() { |
| 115 | byId := HashMap.HashMap<Text, AttestationRecord>( |
| 116 | Nat.max(64, entries.size()), |
| 117 | Text.equal, |
| 118 | Text.hash, |
| 119 | ); |
| 120 | ordered := Buffer.Buffer<AttestationRecord>(Nat.max(64, entries.size())); |
| 121 | let sorted = Array.sort<(Text, AttestationRecord)>( |
| 122 | entries, |
| 123 | func(a : (Text, AttestationRecord), b : (Text, AttestationRecord)) : { |
| 124 | #less; |
| 125 | #equal; |
| 126 | #greater; |
| 127 | } { |
| 128 | if (a.1.seq < b.1.seq) { #less } else if (a.1.seq == b.1.seq) { #equal } else { |
| 129 | #greater; |
| 130 | }; |
| 131 | }, |
| 132 | ); |
| 133 | for ((id, r) in Array.vals(sorted)) { |
| 134 | byId.put(id, r); |
| 135 | ordered.add(r); |
| 136 | }; |
| 137 | }; |
| 138 | loadTransient(); |
| 139 | |
| 140 | func saveStable() { |
| 141 | let buf = Buffer.Buffer<(Text, AttestationRecord)>(ordered.size()); |
| 142 | for (r in ordered.vals()) { |
| 143 | buf.add((r.id, r)); |
| 144 | }; |
| 145 | entries := Buffer.toArray(buf); |
| 146 | }; |
| 147 | |
| 148 | // ------------------------------------------------------------------------- |
| 149 | // Authorization helpers |
| 150 | // ------------------------------------------------------------------------- |
| 151 | |
| 152 | func isAuthorizedCaller(caller : Principal) : Bool { |
| 153 | for (p in Array.vals(authorizedCallers)) { |
| 154 | if (Principal.equal(p, caller)) { return true }; |
| 155 | }; |
| 156 | false; |
| 157 | }; |
| 158 | |
| 159 | func isController(caller : Principal) : Bool { |
| 160 | Principal.isController(caller); |
| 161 | }; |
| 162 | |
| 163 | // ------------------------------------------------------------------------- |
| 164 | // Time helpers (same pattern as hub canister) |
| 165 | // ------------------------------------------------------------------------- |
| 166 | |
| 167 | func intToNatSafe(i : Int) : Nat { |
| 168 | if (i < 0) { return 0 }; |
| 169 | switch (Nat.fromText(Int.toText(i))) { |
| 170 | case null { 0 }; |
| 171 | case (?n) { n }; |
| 172 | }; |
| 173 | }; |
| 174 | |
| 175 | func isLeapYear(y : Nat) : Bool { |
| 176 | (y % 4 == 0 and y % 100 != 0) or (y % 400 == 0); |
| 177 | }; |
| 178 | |
| 179 | func daysInMonth(y : Nat, m : Nat) : Nat { |
| 180 | switch (m) { |
| 181 | case 1 { 31 }; |
| 182 | case 2 { if (isLeapYear(y)) { 29 } else { 28 } }; |
| 183 | case 3 { 31 }; |
| 184 | case 4 { 30 }; |
| 185 | case 5 { 31 }; |
| 186 | case 6 { 30 }; |
| 187 | case 7 { 31 }; |
| 188 | case 8 { 31 }; |
| 189 | case 9 { 30 }; |
| 190 | case 10 { 31 }; |
| 191 | case 11 { 30 }; |
| 192 | case 12 { 31 }; |
| 193 | case _ { 31 }; |
| 194 | }; |
| 195 | }; |
| 196 | |
| 197 | func pad2(n : Nat) : Text { |
| 198 | if (n < 10) { "0" # Nat.toText(n) } else { Nat.toText(n) }; |
| 199 | }; |
| 200 | |
| 201 | func pad3(n : Nat) : Text { |
| 202 | if (n < 10) { "00" # Nat.toText(n) } else if (n < 100) { |
| 203 | "0" # Nat.toText(n); |
| 204 | } else { Nat.toText(n) }; |
| 205 | }; |
| 206 | |
| 207 | func pad4(n : Nat) : Text { |
| 208 | let t = Nat.toText(n); |
| 209 | let len = Text.size(t); |
| 210 | if (len >= 4) { t } else if (len == 3) { "0" # t } else if (len == 2) { |
| 211 | "00" # t; |
| 212 | } else if (len == 1) { "000" # t } else { "0000" }; |
| 213 | }; |
| 214 | |
| 215 | func nowIsoUtc() : Text { |
| 216 | let ns = Time.now(); |
| 217 | if (ns < 0) { return "1970-01-01T00:00:00.000Z" }; |
| 218 | let secInt = ns / 1_000_000_000; |
| 219 | let remNs = ns % 1_000_000_000; |
| 220 | let secNat = intToNatSafe(secInt); |
| 221 | let msNat = intToNatSafe(remNs / 1_000_000); |
| 222 | let secsPerDay = 86400; |
| 223 | let totalDays = secNat / secsPerDay; |
| 224 | var sod = secNat % secsPerDay; |
| 225 | let hour = sod / 3600; |
| 226 | sod := sod % 3600; |
| 227 | let minute = sod / 60; |
| 228 | let second = sod % 60; |
| 229 | var y : Nat = 1970; |
| 230 | var d = totalDays; |
| 231 | label yearLoop loop { |
| 232 | let diy = if (isLeapYear(y)) { 366 } else { 365 }; |
| 233 | if (d >= diy) { d -= diy; y += 1 } else { break yearLoop }; |
| 234 | }; |
| 235 | var m : Nat = 1; |
| 236 | label monthLoop loop { |
| 237 | let dim = daysInMonth(y, m); |
| 238 | if (d >= dim) { d -= dim; m += 1 } else { break monthLoop }; |
| 239 | }; |
| 240 | let day = d + 1; |
| 241 | pad4(y) # "-" # pad2(m) # "-" # pad2(day) # "T" # pad2(hour) # ":" # pad2(minute) # ":" # pad2(second) # "." # pad3(msNat % 1000) # "Z"; |
| 242 | }; |
| 243 | |
| 244 | // ------------------------------------------------------------------------- |
| 245 | // JSON helpers |
| 246 | // ------------------------------------------------------------------------- |
| 247 | |
| 248 | func natToHex4(code : Nat32) : Text { |
| 249 | let n = Nat32.toNat(code); |
| 250 | func hd(div : Nat) : Text { |
| 251 | let dd = (n / div) % 16; |
| 252 | switch (dd) { |
| 253 | case 0 { "0" }; |
| 254 | case 1 { "1" }; |
| 255 | case 2 { "2" }; |
| 256 | case 3 { "3" }; |
| 257 | case 4 { "4" }; |
| 258 | case 5 { "5" }; |
| 259 | case 6 { "6" }; |
| 260 | case 7 { "7" }; |
| 261 | case 8 { "8" }; |
| 262 | case 9 { "9" }; |
| 263 | case 10 { "a" }; |
| 264 | case 11 { "b" }; |
| 265 | case 12 { "c" }; |
| 266 | case 13 { "d" }; |
| 267 | case 14 { "e" }; |
| 268 | case 15 { "f" }; |
| 269 | case _ { "0" }; |
| 270 | }; |
| 271 | }; |
| 272 | hd(4096) # hd(256) # hd(16) # hd(1); |
| 273 | }; |
| 274 | |
| 275 | func escapeJson(s : Text) : Text { |
| 276 | let chars = Text.toArray(s); |
| 277 | var out = ""; |
| 278 | var idx : Nat = 0; |
| 279 | while (idx < chars.size()) { |
| 280 | let ch = chars[idx]; |
| 281 | let code = Char.toNat32(ch); |
| 282 | if (code == 92) { out := out # "\\\\" } else if (code == 34) { |
| 283 | out := out # "\\\""; |
| 284 | } else if (code == 10) { out := out # "\\n" } else if (code == 13) { |
| 285 | out := out # "\\r"; |
| 286 | } else if (code == 9) { out := out # "\\t" } else if (code < 32) { |
| 287 | out := out # "\\u" # natToHex4(code); |
| 288 | } else { out := out # Char.toText(ch) }; |
| 289 | idx += 1; |
| 290 | }; |
| 291 | out; |
| 292 | }; |
| 293 | |
| 294 | func jsonBody(s : Text) : Blob { Text.encodeUtf8(s) }; |
| 295 | |
| 296 | func corsHeaders() : [Header] { |
| 297 | [ |
| 298 | ("Access-Control-Allow-Origin", "*"), |
| 299 | ("Access-Control-Allow-Methods", "GET, OPTIONS"), |
| 300 | ("Access-Control-Allow-Headers", "Content-Type, Accept"), |
| 301 | ("Content-Type", "application/json"), |
| 302 | ]; |
| 303 | }; |
| 304 | |
| 305 | func recordToJson(r : AttestationRecord) : Text { |
| 306 | "{\"id\":\"" # escapeJson(r.id) # "\",\"action\":\"" # escapeJson(r.action) # "\",\"path\":\"" # escapeJson(r.path) # "\",\"timestamp\":\"" # escapeJson(r.timestamp) # "\",\"content_hash\":\"" # escapeJson(r.content_hash) # "\",\"sig\":\"" # escapeJson(r.sig) # "\",\"seq\":" # Nat.toText(r.seq) # ",\"stored_at\":\"" # escapeJson(r.stored_at) # "\"}"; |
| 307 | }; |
| 308 | |
| 309 | // ------------------------------------------------------------------------- |
| 310 | // URL parsing (same pattern as hub canister) |
| 311 | // ------------------------------------------------------------------------- |
| 312 | |
| 313 | func textSlice(t : Text, start : Nat, len : Nat) : Text { |
| 314 | let arr = Text.toArray(t); |
| 315 | let buf = Buffer.Buffer<Char>(len); |
| 316 | var i = start; |
| 317 | var n : Nat = 0; |
| 318 | while (n < len and i < arr.size()) { |
| 319 | buf.add(arr[i]); |
| 320 | i += 1; |
| 321 | n += 1; |
| 322 | }; |
| 323 | Text.fromIter(buf.vals()); |
| 324 | }; |
| 325 | |
| 326 | func textFind(t : Text, needle : Text) : ?Nat { |
| 327 | let tarr = Text.toArray(t); |
| 328 | let narr = Text.toArray(needle); |
| 329 | let nlen = narr.size(); |
| 330 | let tlen = tarr.size(); |
| 331 | if (nlen == 0) { return ?0 }; |
| 332 | if (nlen > tlen) { return null }; |
| 333 | var i : Nat = 0; |
| 334 | while (i + nlen <= tlen) { |
| 335 | var j : Nat = 0; |
| 336 | var ok = true; |
| 337 | while (j < nlen) { |
| 338 | if (tarr[i + j] != narr[j]) { ok := false; j := nlen } else { |
| 339 | j += 1; |
| 340 | }; |
| 341 | }; |
| 342 | if (ok) { return ?i }; |
| 343 | i += 1; |
| 344 | }; |
| 345 | null; |
| 346 | }; |
| 347 | |
| 348 | func pathOnly(rawUrl : Text) : Text { |
| 349 | let pathParts = Iter.toArray(Text.split(rawUrl, #char '?')); |
| 350 | var path = if (pathParts.size() > 0) { pathParts[0] } else { rawUrl }; |
| 351 | switch (textFind(path, "://")) { |
| 352 | case (?k) { |
| 353 | let startAuth = k + 3; |
| 354 | let pathLen = Text.size(path); |
| 355 | if (startAuth < pathLen) { |
| 356 | let afterLen = pathLen - startAuth; |
| 357 | let after = textSlice(path, startAuth, afterLen); |
| 358 | switch (textFind(after, "/")) { |
| 359 | case (?m) { |
| 360 | let afterSz = Text.size(after); |
| 361 | if (m < afterSz) { |
| 362 | path := textSlice(after, m, afterSz - m); |
| 363 | } else { path := "/" }; |
| 364 | }; |
| 365 | case null { path := "/" }; |
| 366 | }; |
| 367 | } else { path := "/" }; |
| 368 | }; |
| 369 | case null {}; |
| 370 | }; |
| 371 | path; |
| 372 | }; |
| 373 | |
| 374 | func decodePercentEncoded(s : Text) : Text { |
| 375 | let chars = Text.toArray(s); |
| 376 | let buf = Buffer.Buffer<Char>(chars.size()); |
| 377 | var i : Nat = 0; |
| 378 | while (i < chars.size()) { |
| 379 | let c = chars[i]; |
| 380 | if (c == '%' and i + 2 < chars.size()) { |
| 381 | switch (hexDigitChar(chars[i + 1]), hexDigitChar(chars[i + 2])) { |
| 382 | case (?a, ?b) { |
| 383 | let code = a * 16 + b; |
| 384 | buf.add(Char.fromNat32(Nat32.fromNat(code))); |
| 385 | i += 3; |
| 386 | }; |
| 387 | case _ { buf.add(c); i += 1 }; |
| 388 | }; |
| 389 | } else { buf.add(c); i += 1 }; |
| 390 | }; |
| 391 | Text.fromIter(buf.vals()); |
| 392 | }; |
| 393 | |
| 394 | func hexDigitChar(ch : Char) : ?Nat { |
| 395 | let n = Char.toNat32(ch); |
| 396 | if (n >= 48 and n <= 57) return ?(Nat32.toNat(n - 48)); |
| 397 | if (n >= 65 and n <= 70) return ?(Nat32.toNat(n - 55)); |
| 398 | if (n >= 97 and n <= 102) return ?(Nat32.toNat(n - 87)); |
| 399 | null; |
| 400 | }; |
| 401 | |
| 402 | // ------------------------------------------------------------------------- |
| 403 | // Candid API — update methods (authenticated) |
| 404 | // ------------------------------------------------------------------------- |
| 405 | |
| 406 | public shared (msg) func storeAttestation(input : StoreInput) : async StoreResult { |
| 407 | if (not isAuthorizedCaller(msg.caller)) { |
| 408 | return #err("Unauthorized: caller " # Principal.toText(msg.caller) # " is not authorized"); |
| 409 | }; |
| 410 | |
| 411 | if (Text.size(input.id) == 0) { |
| 412 | return #err("id is required"); |
| 413 | }; |
| 414 | |
| 415 | switch (byId.get(input.id)) { |
| 416 | case (?_existing) { |
| 417 | return #err("Attestation " # input.id # " already exists (records are immutable)"); |
| 418 | }; |
| 419 | case null {}; |
| 420 | }; |
| 421 | |
| 422 | let seq = nextSeq; |
| 423 | nextSeq += 1; |
| 424 | |
| 425 | let record : AttestationRecord = { |
| 426 | id = input.id; |
| 427 | action = input.action; |
| 428 | path = input.path; |
| 429 | timestamp = input.timestamp; |
| 430 | content_hash = input.content_hash; |
| 431 | sig = input.sig; |
| 432 | seq; |
| 433 | stored_at = nowIsoUtc(); |
| 434 | }; |
| 435 | |
| 436 | byId.put(record.id, record); |
| 437 | ordered.add(record); |
| 438 | saveStable(); |
| 439 | |
| 440 | #ok({ seq }); |
| 441 | }; |
| 442 | |
| 443 | public shared (msg) func setAuthorizedCallers(callers : [Principal]) : async () { |
| 444 | if (not isController(msg.caller)) { |
| 445 | assert (false); |
| 446 | }; |
| 447 | authorizedCallers := callers; |
| 448 | }; |
| 449 | |
| 450 | // ------------------------------------------------------------------------- |
| 451 | // Candid API — query methods (public) |
| 452 | // ------------------------------------------------------------------------- |
| 453 | |
| 454 | public query func getAttestation(id : Text) : async ?AttestationRecord { |
| 455 | byId.get(id); |
| 456 | }; |
| 457 | |
| 458 | public query func listAttestations(offset : Nat, limit : Nat) : async ListResult { |
| 459 | let total = ordered.size(); |
| 460 | let effectiveLimit = if (limit > 100) { 100 } else if (limit == 0) { 20 } else { |
| 461 | limit; |
| 462 | }; |
| 463 | let start = if (offset >= total) { total } else { offset }; |
| 464 | let end = Nat.min(start + effectiveLimit, total); |
| 465 | let buf = Buffer.Buffer<AttestationRecord>(end - start); |
| 466 | var i = start; |
| 467 | while (i < end) { |
| 468 | buf.add(ordered.get(i)); |
| 469 | i += 1; |
| 470 | }; |
| 471 | { records = Buffer.toArray(buf); total }; |
| 472 | }; |
| 473 | |
| 474 | public query func getStats() : async { total : Nat; nextSeq : Nat } { |
| 475 | { total = ordered.size(); nextSeq }; |
| 476 | }; |
| 477 | |
| 478 | public query func getAuthorizedCallers() : async [Principal] { |
| 479 | authorizedCallers; |
| 480 | }; |
| 481 | |
| 482 | // ------------------------------------------------------------------------- |
| 483 | // HTTP interface — browser-based verification (read-only) |
| 484 | // ------------------------------------------------------------------------- |
| 485 | |
| 486 | public query func http_request(req : HttpRequest) : async HttpResponse { |
| 487 | let path = pathOnly(req.url); |
| 488 | |
| 489 | if (path == "/health" or path == "/health/") { |
| 490 | return { |
| 491 | status_code = 200; |
| 492 | headers = corsHeaders(); |
| 493 | body = jsonBody("{\"ok\":true,\"canister\":\"attestation\"}"); |
| 494 | streaming_strategy = null; |
| 495 | upgrade = null; |
| 496 | }; |
| 497 | }; |
| 498 | |
| 499 | if (path == "/stats" or path == "/stats/") { |
| 500 | return { |
| 501 | status_code = 200; |
| 502 | headers = corsHeaders(); |
| 503 | body = jsonBody( |
| 504 | "{\"total\":" # Nat.toText(ordered.size()) # ",\"next_seq\":" # Nat.toText(nextSeq) # "}" |
| 505 | ); |
| 506 | streaming_strategy = null; |
| 507 | upgrade = null; |
| 508 | }; |
| 509 | }; |
| 510 | |
| 511 | if (Text.startsWith(path, #text "/attest/")) { |
| 512 | let rawId = Text.trimStart(path, #text "/attest/"); |
| 513 | let id = decodePercentEncoded(rawId); |
| 514 | switch (byId.get(id)) { |
| 515 | case (?r) { |
| 516 | return { |
| 517 | status_code = 200; |
| 518 | headers = corsHeaders(); |
| 519 | body = jsonBody(recordToJson(r)); |
| 520 | streaming_strategy = null; |
| 521 | upgrade = null; |
| 522 | }; |
| 523 | }; |
| 524 | case null { |
| 525 | return { |
| 526 | status_code = 404; |
| 527 | headers = corsHeaders(); |
| 528 | body = jsonBody("{\"error\":\"Attestation not found\",\"code\":\"NOT_FOUND\"}"); |
| 529 | streaming_strategy = null; |
| 530 | upgrade = null; |
| 531 | }; |
| 532 | }; |
| 533 | }; |
| 534 | }; |
| 535 | |
| 536 | if (req.method == "OPTIONS") { |
| 537 | return { |
| 538 | status_code = 204; |
| 539 | headers = corsHeaders(); |
| 540 | body = jsonBody(""); |
| 541 | streaming_strategy = null; |
| 542 | upgrade = null; |
| 543 | }; |
| 544 | }; |
| 545 | |
| 546 | { |
| 547 | status_code = 404; |
| 548 | headers = corsHeaders(); |
| 549 | body = jsonBody("{\"error\":\"Not found\",\"code\":\"NOT_FOUND\"}"); |
| 550 | streaming_strategy = null; |
| 551 | upgrade = null; |
| 552 | }; |
| 553 | }; |
| 554 | }; |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago